Compare commits
2 Commits
draft/mosa
...
feat/insta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dd9806af1 | ||
|
|
745b9c4967 |
@@ -7,12 +7,10 @@ import { FederationController } from './federation.controller.js';
|
|||||||
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
||||||
import { GrantsService } from './grants.service.js';
|
import { GrantsService } from './grants.service.js';
|
||||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
import { FederationAuthGuard } from './server/index.js';
|
||||||
import { ListController } from './server/verbs/list.controller.js';
|
|
||||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
controllers: [EnrollmentController, FederationController, CapabilitiesController],
|
||||||
providers: [
|
providers: [
|
||||||
AdminGuard,
|
AdminGuard,
|
||||||
CaService,
|
CaService,
|
||||||
@@ -21,8 +19,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationClientService,
|
FederationClientService,
|
||||||
QuerySourceService,
|
QuerySourceService,
|
||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
|
||||||
FederationListQueryService,
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CaService,
|
CaService,
|
||||||
@@ -31,8 +27,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationClientService,
|
FederationClientService,
|
||||||
QuerySourceService,
|
QuerySourceService,
|
||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
|
||||||
FederationListQueryService,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
@@ -1,324 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for FederationScopeService (FED-M3-04).
|
|
||||||
*
|
|
||||||
* Coverage:
|
|
||||||
* - resource allowlist deny
|
|
||||||
* - excluded resource deny
|
|
||||||
* - invalid scope deny
|
|
||||||
* - invalid requested limit deny
|
|
||||||
* - native RBAC deny as subjectUserId
|
|
||||||
* - scope/native filter intersection for personal and team rows
|
|
||||||
* - native RBAC personal deny wins over scope include_personal allow/default
|
|
||||||
* - max_rows_per_query cap
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { FederationScopeService, type FederationNativeRbacEvaluator } from '../scope.service.js';
|
|
||||||
import type { FederationContext } from '../federation-context.js';
|
|
||||||
|
|
||||||
const GRANT_ID = 'grant-1';
|
|
||||||
const PEER_ID = 'peer-1';
|
|
||||||
const SUBJECT_USER_ID = 'user-1';
|
|
||||||
|
|
||||||
function makeContext(scope: Record<string, unknown>): FederationContext {
|
|
||||||
return {
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
peerId: PEER_ID,
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
scope,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeNativeRbac(
|
|
||||||
result: Awaited<ReturnType<FederationNativeRbacEvaluator['evaluateReadAccess']>>,
|
|
||||||
): FederationNativeRbacEvaluator {
|
|
||||||
return {
|
|
||||||
evaluateReadAccess: vi.fn().mockResolvedValue(result),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('FederationScopeService', () => {
|
|
||||||
let service: FederationScopeService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
service = new FederationScopeService();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows a granted resource and returns a capped query filter', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({
|
|
||||||
resources: ['tasks'],
|
|
||||||
filters: { tasks: { include_teams: ['team-1', 'team-3'], include_personal: true } },
|
|
||||||
max_rows_per_query: 50,
|
|
||||||
}),
|
|
||||||
resource: 'tasks',
|
|
||||||
requestedLimit: 500,
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
resource: 'tasks',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: ['team-1'],
|
|
||||||
limit: 50,
|
|
||||||
maxRowsPerQuery: 50,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).toHaveBeenCalledWith({
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
peerId: PEER_ID,
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
resource: 'tasks',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults absent resource filters to native RBAC personal and team visibility', async () => {
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: ['notes'], max_rows_per_query: 100 }),
|
|
||||||
resource: 'notes',
|
|
||||||
nativeRbac: makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: ['team-1', 'team-2'],
|
|
||||||
limit: 100,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('honors include_personal false even when native RBAC allows personal rows', async () => {
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({
|
|
||||||
resources: ['memory'],
|
|
||||||
filters: { memory: { include_personal: false } },
|
|
||||||
max_rows_per_query: 25,
|
|
||||||
}),
|
|
||||||
resource: 'memory',
|
|
||||||
nativeRbac: makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not leak personal rows when scope allows personal but native RBAC denies personal', async () => {
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({
|
|
||||||
resources: ['tasks'],
|
|
||||||
filters: { tasks: { include_personal: true } },
|
|
||||||
max_rows_per_query: 25,
|
|
||||||
}),
|
|
||||||
resource: 'tasks',
|
|
||||||
nativeRbac: makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: false, teamIds: ['team-1'] },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: ['team-1'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not widen native RBAC when scope includes teams the user cannot access', async () => {
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({
|
|
||||||
resources: ['tasks'],
|
|
||||||
filters: { tasks: { include_teams: ['team-2'], include_personal: false } },
|
|
||||||
max_rows_per_query: 25,
|
|
||||||
}),
|
|
||||||
resource: 'tasks',
|
|
||||||
nativeRbac: makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: ['team-1'] },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies invalid grant scope before RBAC evaluation', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: [], max_rows_per_query: 100 }),
|
|
||||||
resource: 'tasks',
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'invalid_scope',
|
|
||||||
stage: 'scope_parse',
|
|
||||||
statusCode: 400,
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
resource: 'tasks',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies unsupported resource names before RBAC evaluation', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
|
||||||
resource: 'unknown_resource',
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'invalid_resource',
|
|
||||||
stage: 'resource_allowlist',
|
|
||||||
statusCode: 403,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies resources explicitly present in excluded_resources before allowlist miss', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({
|
|
||||||
resources: ['tasks'],
|
|
||||||
excluded_resources: ['credentials'],
|
|
||||||
max_rows_per_query: 100,
|
|
||||||
}),
|
|
||||||
resource: 'credentials',
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'resource_excluded',
|
|
||||||
stage: 'resource_exclusion',
|
|
||||||
statusCode: 403,
|
|
||||||
resource: 'credentials',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies supported resources that are not granted by scope', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
|
||||||
resource: 'notes',
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'resource_not_granted',
|
|
||||||
stage: 'resource_allowlist',
|
|
||||||
statusCode: 403,
|
|
||||||
resource: 'notes',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies invalid requested row limits before RBAC evaluation', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
|
||||||
resource: 'tasks',
|
|
||||||
requestedLimit: 0,
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'invalid_limit',
|
|
||||||
stage: 'row_cap',
|
|
||||||
statusCode: 400,
|
|
||||||
details: { requestedLimit: 0 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies when native RBAC rejects subjectUserId access to the resource', async () => {
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
|
||||||
resource: 'tasks',
|
|
||||||
nativeRbac: makeNativeRbac({
|
|
||||||
allowed: false,
|
|
||||||
reason: 'read:tasks denied',
|
|
||||||
details: { permission: 'tasks:read' },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'native_rbac_denied',
|
|
||||||
stage: 'native_rbac',
|
|
||||||
statusCode: 403,
|
|
||||||
message: 'read:tasks denied',
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
peerId: PEER_ID,
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
resource: 'tasks',
|
|
||||||
details: { permission: 'tasks:read' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,22 +10,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { FederationAuthGuard } from './federation-auth.guard.js';
|
export { FederationAuthGuard } from './federation-auth.guard.js';
|
||||||
export { FederationScopeService } from './scope.service.js';
|
|
||||||
export type { FederationContext } from './federation-context.js';
|
export type { FederationContext } from './federation-context.js';
|
||||||
export type {
|
|
||||||
FederationNativeRbacAccess,
|
|
||||||
FederationNativeRbacAllowedResult,
|
|
||||||
FederationNativeRbacDeniedResult,
|
|
||||||
FederationNativeRbacEvaluator,
|
|
||||||
FederationNativeRbacRequest,
|
|
||||||
FederationNativeRbacResult,
|
|
||||||
FederationScopeAllowedResult,
|
|
||||||
FederationScopeDeniedResult,
|
|
||||||
FederationScopeDenyCode,
|
|
||||||
FederationScopeDenyDetails,
|
|
||||||
FederationScopeDenyReason,
|
|
||||||
FederationScopeDenyStage,
|
|
||||||
FederationScopeEvaluationInput,
|
|
||||||
FederationScopeEvaluationResult,
|
|
||||||
FederationScopeQueryFilter,
|
|
||||||
} from './scope.service.js';
|
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
/**
|
|
||||||
* FederationScopeService — M3 server-side scope enforcement pipeline.
|
|
||||||
*
|
|
||||||
* Pure trust-boundary service: it validates the grant scope, asks an injected
|
|
||||||
* native RBAC evaluator what the subject user can read locally, intersects that
|
|
||||||
* answer with the federation scope filters, and returns a query filter for the
|
|
||||||
* verb controllers. The service performs no DB calls directly.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
FEDERATION_RESOURCE_VALUES,
|
|
||||||
type FederationResource,
|
|
||||||
FederationScopeError,
|
|
||||||
parseFederationScope,
|
|
||||||
} from '../scope-schema.js';
|
|
||||||
import type { FederationContext } from './federation-context.js';
|
|
||||||
|
|
||||||
const federationResourceSet: ReadonlySet<string> = new Set<string>(FEDERATION_RESOURCE_VALUES);
|
|
||||||
|
|
||||||
export type FederationScopeDenyStage =
|
|
||||||
| 'scope_parse'
|
|
||||||
| 'resource_allowlist'
|
|
||||||
| 'resource_exclusion'
|
|
||||||
| 'native_rbac'
|
|
||||||
| 'row_cap';
|
|
||||||
|
|
||||||
export type FederationScopeDenyCode =
|
|
||||||
| 'invalid_scope'
|
|
||||||
| 'invalid_resource'
|
|
||||||
| 'resource_not_granted'
|
|
||||||
| 'resource_excluded'
|
|
||||||
| 'native_rbac_denied'
|
|
||||||
| 'invalid_limit';
|
|
||||||
|
|
||||||
export type FederationScopeDenyStatus = 400 | 403;
|
|
||||||
|
|
||||||
export interface FederationScopeDenyDetails {
|
|
||||||
readonly [key: string]: string | number | boolean | readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationScopeDenyReason {
|
|
||||||
readonly code: FederationScopeDenyCode;
|
|
||||||
readonly stage: FederationScopeDenyStage;
|
|
||||||
readonly statusCode: FederationScopeDenyStatus;
|
|
||||||
readonly message: string;
|
|
||||||
readonly grantId: string;
|
|
||||||
readonly peerId: string;
|
|
||||||
readonly subjectUserId: string;
|
|
||||||
readonly resource: string;
|
|
||||||
readonly details?: FederationScopeDenyDetails;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationNativeRbacRequest {
|
|
||||||
readonly grantId: string;
|
|
||||||
readonly peerId: string;
|
|
||||||
readonly subjectUserId: string;
|
|
||||||
readonly resource: FederationResource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationNativeRbacAccess {
|
|
||||||
/** Whether this user may read personal rows for this resource. */
|
|
||||||
readonly includePersonal: boolean;
|
|
||||||
|
|
||||||
/** Team IDs this user may read for this resource under native RBAC. */
|
|
||||||
readonly teamIds: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationNativeRbacAllowedResult {
|
|
||||||
readonly allowed: true;
|
|
||||||
readonly access: FederationNativeRbacAccess;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationNativeRbacDeniedResult {
|
|
||||||
readonly allowed: false;
|
|
||||||
readonly reason?: string;
|
|
||||||
readonly details?: FederationScopeDenyDetails;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FederationNativeRbacResult =
|
|
||||||
| FederationNativeRbacAllowedResult
|
|
||||||
| FederationNativeRbacDeniedResult;
|
|
||||||
|
|
||||||
export interface FederationNativeRbacEvaluator {
|
|
||||||
evaluateReadAccess(request: FederationNativeRbacRequest): Promise<FederationNativeRbacResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationScopeEvaluationInput {
|
|
||||||
readonly context: FederationContext;
|
|
||||||
readonly resource: string;
|
|
||||||
readonly requestedLimit?: number;
|
|
||||||
readonly nativeRbac: FederationNativeRbacEvaluator;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationScopeQueryFilter {
|
|
||||||
readonly resource: FederationResource;
|
|
||||||
readonly subjectUserId: string;
|
|
||||||
readonly includePersonal: boolean;
|
|
||||||
readonly teamIds: readonly string[];
|
|
||||||
readonly limit: number;
|
|
||||||
readonly maxRowsPerQuery: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationScopeAllowedResult {
|
|
||||||
readonly allowed: true;
|
|
||||||
readonly filter: FederationScopeQueryFilter;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationScopeDeniedResult {
|
|
||||||
readonly allowed: false;
|
|
||||||
readonly deny: FederationScopeDenyReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FederationScopeEvaluationResult =
|
|
||||||
| FederationScopeAllowedResult
|
|
||||||
| FederationScopeDeniedResult;
|
|
||||||
|
|
||||||
function isFederationResource(resource: string): resource is FederationResource {
|
|
||||||
return federationResourceSet.has(resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueStrings(values: readonly string[]): readonly string[] {
|
|
||||||
return Array.from(new Set<string>(values));
|
|
||||||
}
|
|
||||||
|
|
||||||
function intersectTeamIds(
|
|
||||||
nativeTeamIds: readonly string[],
|
|
||||||
scopedTeamIds: readonly string[] | undefined,
|
|
||||||
): readonly string[] {
|
|
||||||
const uniqueNativeTeamIds = uniqueStrings(nativeTeamIds);
|
|
||||||
|
|
||||||
if (scopedTeamIds === undefined) {
|
|
||||||
return uniqueNativeTeamIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nativeSet = new Set<string>(uniqueNativeTeamIds);
|
|
||||||
return uniqueStrings(scopedTeamIds).filter((teamId: string): boolean => nativeSet.has(teamId));
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDenyReason(params: {
|
|
||||||
readonly code: FederationScopeDenyCode;
|
|
||||||
readonly stage: FederationScopeDenyStage;
|
|
||||||
readonly statusCode?: FederationScopeDenyStatus;
|
|
||||||
readonly message: string;
|
|
||||||
readonly context: FederationContext;
|
|
||||||
readonly resource: string;
|
|
||||||
readonly details?: FederationScopeDenyDetails;
|
|
||||||
}): FederationScopeDeniedResult {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: params.code,
|
|
||||||
stage: params.stage,
|
|
||||||
statusCode: params.statusCode ?? 403,
|
|
||||||
message: params.message,
|
|
||||||
grantId: params.context.grantId,
|
|
||||||
peerId: params.context.peerId,
|
|
||||||
subjectUserId: params.context.subjectUserId,
|
|
||||||
resource: params.resource,
|
|
||||||
...(params.details !== undefined ? { details: params.details } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FederationScopeService {
|
|
||||||
async evaluateAccess(
|
|
||||||
input: FederationScopeEvaluationInput,
|
|
||||||
): Promise<FederationScopeEvaluationResult> {
|
|
||||||
const { context, resource, requestedLimit, nativeRbac } = input;
|
|
||||||
|
|
||||||
let scope: ReturnType<typeof parseFederationScope>;
|
|
||||||
try {
|
|
||||||
scope = parseFederationScope(context.scope);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message =
|
|
||||||
error instanceof FederationScopeError
|
|
||||||
? 'Federation grant scope is invalid'
|
|
||||||
: 'Federation grant scope could not be parsed';
|
|
||||||
const details = error instanceof Error ? { reason: error.message } : undefined;
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'invalid_scope',
|
|
||||||
stage: 'scope_parse',
|
|
||||||
statusCode: 400,
|
|
||||||
message,
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
...(details !== undefined ? { details } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isFederationResource(resource)) {
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'invalid_resource',
|
|
||||||
stage: 'resource_allowlist',
|
|
||||||
message: 'Requested federation resource is not supported',
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
details: { supportedResources: FEDERATION_RESOURCE_VALUES },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scope.excluded_resources.includes(resource)) {
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'resource_excluded',
|
|
||||||
stage: 'resource_exclusion',
|
|
||||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!scope.resources.includes(resource)) {
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'resource_not_granted',
|
|
||||||
stage: 'resource_allowlist',
|
|
||||||
message: 'Requested federation resource is not granted by scope',
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
details: { grantedResources: scope.resources },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestedLimit !== undefined && (!Number.isInteger(requestedLimit) || requestedLimit < 1)) {
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'invalid_limit',
|
|
||||||
stage: 'row_cap',
|
|
||||||
statusCode: 400,
|
|
||||||
message: 'Requested row limit must be a positive integer',
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
details: { requestedLimit },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const nativeResult = await nativeRbac.evaluateReadAccess({
|
|
||||||
grantId: context.grantId,
|
|
||||||
peerId: context.peerId,
|
|
||||||
subjectUserId: context.subjectUserId,
|
|
||||||
resource,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!nativeResult.allowed) {
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'native_rbac_denied',
|
|
||||||
stage: 'native_rbac',
|
|
||||||
message: nativeResult.reason ?? 'Subject user is not allowed to read this resource',
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
...(nativeResult.details !== undefined ? { details: nativeResult.details } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopeFilter = scope.filters?.[resource];
|
|
||||||
const includePersonal =
|
|
||||||
Boolean(scopeFilter?.include_personal ?? true) && nativeResult.access.includePersonal;
|
|
||||||
const teamIds = intersectTeamIds(nativeResult.access.teamIds, scopeFilter?.include_teams);
|
|
||||||
const limit = Math.min(requestedLimit ?? scope.max_rows_per_query, scope.max_rows_per_query);
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
resource,
|
|
||||||
subjectUserId: context.subjectUserId,
|
|
||||||
includePersonal,
|
|
||||||
teamIds,
|
|
||||||
limit,
|
|
||||||
maxRowsPerQuery: scope.max_rows_per_query,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
createPgliteDb,
|
|
||||||
insights,
|
|
||||||
missionTasks,
|
|
||||||
missions,
|
|
||||||
preferences,
|
|
||||||
projects,
|
|
||||||
runPgliteMigrations,
|
|
||||||
teams,
|
|
||||||
users,
|
|
||||||
type Db,
|
|
||||||
type DbHandle,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import type { FederationScopeQueryFilter } from '../../scope.service.js';
|
|
||||||
import { FederationListQueryService } from '../list-query.service.js';
|
|
||||||
|
|
||||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
|
||||||
resource: 'tasks',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: [],
|
|
||||||
limit: 2,
|
|
||||||
maxRowsPerQuery: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SUBJECT_USER_ID = 'fed-m3-05-subject';
|
|
||||||
const OTHER_USER_ID = 'fed-m3-05-other';
|
|
||||||
const TEAM_ID = '05000000-0000-4000-8000-000000000001';
|
|
||||||
const UNAUTHORIZED_TEAM_ID = '05000000-0000-4000-8000-000000000002';
|
|
||||||
const PERSONAL_PROJECT_ID = '05000000-0000-4000-8000-000000000101';
|
|
||||||
const TEAM_PROJECT_ID = '05000000-0000-4000-8000-000000000102';
|
|
||||||
const UNAUTHORIZED_PROJECT_ID = '05000000-0000-4000-8000-000000000103';
|
|
||||||
const PERSONAL_MISSION_ID = '05000000-0000-4000-8000-000000000201';
|
|
||||||
const TEAM_MISSION_ID = '05000000-0000-4000-8000-000000000202';
|
|
||||||
const UNAUTHORIZED_MISSION_ID = '05000000-0000-4000-8000-000000000203';
|
|
||||||
const SUBJECT_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000301';
|
|
||||||
const OTHER_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000302';
|
|
||||||
const SUBJECT_PERSONAL_NOTE_ID = '05000000-0000-4000-8000-000000000303';
|
|
||||||
const SUBJECT_UNAUTHORIZED_NOTE_ID = '05000000-0000-4000-8000-000000000304';
|
|
||||||
const INSIGHT_ONE_ID = '05000000-0000-4000-8000-000000000401';
|
|
||||||
const INSIGHT_TWO_ID = '05000000-0000-4000-8000-000000000402';
|
|
||||||
const PREFERENCE_ONE_ID = '05000000-0000-4000-8000-000000000501';
|
|
||||||
const PREFERENCE_TWO_ID = '05000000-0000-4000-8000-000000000502';
|
|
||||||
|
|
||||||
let dbHandle: DbHandle | undefined;
|
|
||||||
|
|
||||||
function makeService() {
|
|
||||||
return new FederationListQueryService({} as Db);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDbService() {
|
|
||||||
if (!dbHandle) {
|
|
||||||
throw new Error('test DB not initialized');
|
|
||||||
}
|
|
||||||
return new FederationListQueryService(dbHandle.db);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedNotesFixture() {
|
|
||||||
if (!dbHandle) {
|
|
||||||
throw new Error('test DB not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
await dbHandle.db.insert(users).values([
|
|
||||||
{
|
|
||||||
id: SUBJECT_USER_ID,
|
|
||||||
name: 'Federation Subject',
|
|
||||||
email: `${SUBJECT_USER_ID}@example.test`,
|
|
||||||
emailVerified: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: OTHER_USER_ID,
|
|
||||||
name: 'Federation Other',
|
|
||||||
email: `${OTHER_USER_ID}@example.test`,
|
|
||||||
emailVerified: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(teams).values([
|
|
||||||
{
|
|
||||||
id: TEAM_ID,
|
|
||||||
name: 'FED-M3-05 Team',
|
|
||||||
slug: 'fed-m3-05-team',
|
|
||||||
ownerId: SUBJECT_USER_ID,
|
|
||||||
managerId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: UNAUTHORIZED_TEAM_ID,
|
|
||||||
name: 'FED-M3-05 Unauthorized Team',
|
|
||||||
slug: 'fed-m3-05-unauthorized-team',
|
|
||||||
ownerId: OTHER_USER_ID,
|
|
||||||
managerId: OTHER_USER_ID,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(projects).values([
|
|
||||||
{
|
|
||||||
id: PERSONAL_PROJECT_ID,
|
|
||||||
name: 'FED-M3-05 Personal Project',
|
|
||||||
ownerId: SUBJECT_USER_ID,
|
|
||||||
ownerType: 'user',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: TEAM_PROJECT_ID,
|
|
||||||
name: 'FED-M3-05 Team Project',
|
|
||||||
teamId: TEAM_ID,
|
|
||||||
ownerType: 'team',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: UNAUTHORIZED_PROJECT_ID,
|
|
||||||
name: 'FED-M3-05 Unauthorized Project',
|
|
||||||
teamId: UNAUTHORIZED_TEAM_ID,
|
|
||||||
ownerType: 'team',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(missions).values([
|
|
||||||
{
|
|
||||||
id: PERSONAL_MISSION_ID,
|
|
||||||
name: 'FED-M3-05 Personal Mission',
|
|
||||||
projectId: PERSONAL_PROJECT_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: TEAM_MISSION_ID,
|
|
||||||
name: 'FED-M3-05 Team Mission',
|
|
||||||
projectId: TEAM_PROJECT_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: UNAUTHORIZED_MISSION_ID,
|
|
||||||
name: 'FED-M3-05 Unauthorized Mission',
|
|
||||||
projectId: UNAUTHORIZED_PROJECT_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(missionTasks).values([
|
|
||||||
{
|
|
||||||
id: SUBJECT_TEAM_NOTE_ID,
|
|
||||||
missionId: TEAM_MISSION_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
notes: 'subject note on team mission',
|
|
||||||
createdAt: new Date('2026-06-24T03:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: OTHER_TEAM_NOTE_ID,
|
|
||||||
missionId: TEAM_MISSION_ID,
|
|
||||||
userId: OTHER_USER_ID,
|
|
||||||
notes: 'other user note on team mission',
|
|
||||||
createdAt: new Date('2026-06-24T02:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
|
||||||
missionId: PERSONAL_MISSION_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
notes: 'subject note on personal mission',
|
|
||||||
createdAt: new Date('2026-06-24T01:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
|
||||||
missionId: UNAUTHORIZED_MISSION_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
notes: 'subject note outside grant-visible missions',
|
|
||||||
createdAt: new Date('2026-06-24T04:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const memoryCreatedAt = new Date('2026-06-24T05:00:00.000Z');
|
|
||||||
await dbHandle.db.insert(insights).values([
|
|
||||||
{
|
|
||||||
id: INSIGHT_ONE_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
content: 'first insight',
|
|
||||||
source: 'agent',
|
|
||||||
createdAt: memoryCreatedAt,
|
|
||||||
updatedAt: memoryCreatedAt,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: INSIGHT_TWO_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
content: 'second insight',
|
|
||||||
source: 'agent',
|
|
||||||
createdAt: memoryCreatedAt,
|
|
||||||
updatedAt: memoryCreatedAt,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(preferences).values([
|
|
||||||
{
|
|
||||||
id: PREFERENCE_ONE_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
key: 'fed-m3-05-pref-1',
|
|
||||||
value: { enabled: true },
|
|
||||||
createdAt: memoryCreatedAt,
|
|
||||||
updatedAt: memoryCreatedAt,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: PREFERENCE_TWO_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
key: 'fed-m3-05-pref-2',
|
|
||||||
value: { enabled: false },
|
|
||||||
createdAt: memoryCreatedAt,
|
|
||||||
updatedAt: memoryCreatedAt,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stubRows(
|
|
||||||
service: FederationListQueryService,
|
|
||||||
...pages: Array<Array<Record<string, unknown>>>
|
|
||||||
) {
|
|
||||||
const mock = vi.fn();
|
|
||||||
for (const page of pages) {
|
|
||||||
mock.mockResolvedValueOnce(page);
|
|
||||||
}
|
|
||||||
(
|
|
||||||
service as unknown as {
|
|
||||||
listAllRows: (
|
|
||||||
_filter: FederationScopeQueryFilter,
|
|
||||||
_rowLimit: number,
|
|
||||||
_cursor: unknown,
|
|
||||||
) => Promise<Array<Record<string, unknown>>>;
|
|
||||||
}
|
|
||||||
).listAllRows = mock;
|
|
||||||
return mock;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('FederationListQueryService', () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
dbHandle = createPgliteDb(`memory://fed-m3-05-list-${Date.now()}`);
|
|
||||||
await runPgliteMigrations(dbHandle);
|
|
||||||
await seedNotesFixture();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await dbHandle?.close();
|
|
||||||
dbHandle = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies sensitive resources in native RBAC for M3 list reads', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.evaluateReadAccess({
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
resource: 'credentials',
|
|
||||||
}),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
reason: 'credentials federation list access is not implemented in M3',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows personal memory reads without requiring team lookup', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.evaluateReadAccess({
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
resource: 'memory',
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies the scope row cap and returns an opaque next cursor when truncated', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
const listAllRows = stubRows(
|
|
||||||
service,
|
|
||||||
[
|
|
||||||
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
|
|
||||||
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
|
|
||||||
{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') },
|
|
||||||
],
|
|
||||||
[{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
|
|
||||||
);
|
|
||||||
|
|
||||||
const firstPage = await service.list({ filter: TASK_FILTER });
|
|
||||||
|
|
||||||
expect(firstPage).toEqual({
|
|
||||||
items: [
|
|
||||||
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
|
|
||||||
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
|
|
||||||
],
|
|
||||||
truncated: true,
|
|
||||||
nextCursor: expect.any(String),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(listAllRows).toHaveBeenNthCalledWith(1, TASK_FILTER, 3, undefined);
|
|
||||||
|
|
||||||
const secondPage = await service.list({ filter: TASK_FILTER, cursor: firstPage.nextCursor });
|
|
||||||
expect(secondPage).toEqual({
|
|
||||||
items: [{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
|
|
||||||
truncated: false,
|
|
||||||
});
|
|
||||||
expect(listAllRows).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
TASK_FILTER,
|
|
||||||
3,
|
|
||||||
expect.objectContaining({ id: '2' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid cursors instead of falling back to the first page', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
stubRows(service, [{ id: '1' }]);
|
|
||||||
|
|
||||||
await expect(service.list({ filter: TASK_FILTER, cursor: 'not-base64-json' })).rejects.toThrow(
|
|
||||||
'Invalid federation list cursor',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when a truncated page cannot encode a resumable cursor', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
stubRows(service, [
|
|
||||||
{ id: '2', createdAt: 'not-a-date' },
|
|
||||||
{ id: '1', createdAt: 'not-a-date' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
await expect(service.list({ filter: { ...TASK_FILTER, limit: 1 } })).rejects.toThrow(
|
|
||||||
'Federation list cursor cannot be encoded',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws on unsupported resources instead of crashing pagination', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.list({
|
|
||||||
filter: {
|
|
||||||
...TASK_FILTER,
|
|
||||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('Unsupported federation list resource');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not leak another user mission task notes through team-scoped note reads', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
|
|
||||||
const result = await service.list({
|
|
||||||
filter: {
|
|
||||||
resource: 'notes',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [TEAM_ID],
|
|
||||||
limit: 10,
|
|
||||||
maxRowsPerQuery: 10,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ids = result.items.map((item) => item['id']);
|
|
||||||
expect(ids).toEqual([SUBJECT_TEAM_NOTE_ID]);
|
|
||||||
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not return subject personal mission task notes when includePersonal is false', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
|
|
||||||
const result = await service.list({
|
|
||||||
filter: {
|
|
||||||
resource: 'notes',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [TEAM_ID],
|
|
||||||
limit: 10,
|
|
||||||
maxRowsPerQuery: 10,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.items.map((item) => item['id'])).not.toContain(SUBJECT_PERSONAL_NOTE_ID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not return subject notes from missions outside the grant-visible project set', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
|
|
||||||
const result = await service.list({
|
|
||||||
filter: {
|
|
||||||
resource: 'notes',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: [TEAM_ID],
|
|
||||||
limit: 10,
|
|
||||||
maxRowsPerQuery: 10,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ids = result.items.map((item) => item['id']);
|
|
||||||
expect(ids).toContain(SUBJECT_PERSONAL_NOTE_ID);
|
|
||||||
expect(ids).toContain(SUBJECT_TEAM_NOTE_ID);
|
|
||||||
expect(ids).not.toContain(SUBJECT_UNAUTHORIZED_NOTE_ID);
|
|
||||||
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('paginates memory deterministically across insights and preferences', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
const filter: FederationScopeQueryFilter = {
|
|
||||||
resource: 'memory',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: [],
|
|
||||||
limit: 2,
|
|
||||||
maxRowsPerQuery: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const firstPage = await service.list({ filter });
|
|
||||||
const secondPage = await service.list({ filter, cursor: firstPage.nextCursor });
|
|
||||||
const firstPageIds = firstPage.items.map((item) => item['id']);
|
|
||||||
const secondPageIds = secondPage.items.map((item) => item['id']);
|
|
||||||
const allIds = [...firstPageIds, ...secondPageIds];
|
|
||||||
|
|
||||||
expect(firstPage).toMatchObject({ truncated: true, nextCursor: expect.any(String) });
|
|
||||||
expect(firstPageIds).toEqual([INSIGHT_TWO_ID, INSIGHT_ONE_ID]);
|
|
||||||
expect(secondPageIds).toEqual([PREFERENCE_TWO_ID, PREFERENCE_ONE_ID]);
|
|
||||||
expect(new Set(allIds).size).toBe(allIds.length);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { RequestMethod } from '@nestjs/common';
|
|
||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
|
||||||
import type {
|
|
||||||
FederationScopeEvaluationResult,
|
|
||||||
FederationScopeQueryFilter,
|
|
||||||
} from '../../scope.service.js';
|
|
||||||
import { ListController } from '../list.controller.js';
|
|
||||||
import type { FederationListQueryResult } from '../list-query.service.js';
|
|
||||||
|
|
||||||
const FEDERATION_CONTEXT = {
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
scope: { resources: ['tasks'], max_rows_per_query: 25 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
|
||||||
resource: 'tasks',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: ['team-1'],
|
|
||||||
limit: 10,
|
|
||||||
maxRowsPerQuery: 25,
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeRequest(): FastifyRequest {
|
|
||||||
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function allowedScope(
|
|
||||||
filter: FederationScopeQueryFilter = TASK_FILTER,
|
|
||||||
): FederationScopeEvaluationResult {
|
|
||||||
return { allowed: true, filter };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeController(opts?: {
|
|
||||||
scopeResult?: FederationScopeEvaluationResult;
|
|
||||||
queryResult?: FederationListQueryResult;
|
|
||||||
}) {
|
|
||||||
const scope = {
|
|
||||||
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
|
|
||||||
};
|
|
||||||
const query = {
|
|
||||||
evaluateReadAccess: vi.fn(),
|
|
||||||
list: vi.fn().mockResolvedValue(
|
|
||||||
opts?.queryResult ?? {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Federated task',
|
|
||||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
truncated: false,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
controller: new ListController(scope as never, query as never),
|
|
||||||
scope,
|
|
||||||
query,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ListController', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('declares POST /api/federation/v1/list/:resource protected only by FederationAuthGuard', () => {
|
|
||||||
expect(Reflect.getMetadata('path', ListController)).toBe('api/federation/v1/list');
|
|
||||||
expect(Reflect.getMetadata('path', ListController.prototype.list)).toBe(':resource');
|
|
||||||
expect(Reflect.getMetadata('method', ListController.prototype.list)).toBe(RequestMethod.POST);
|
|
||||||
expect(Reflect.getMetadata('__guards__', ListController)).toEqual([FederationAuthGuard]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs AuthGuard context through ScopeService and returns local-source tagged rows', async () => {
|
|
||||||
const { controller, scope, query } = makeController();
|
|
||||||
|
|
||||||
const response = await controller.list('tasks', makeRequest(), { limit: 10 });
|
|
||||||
|
|
||||||
expect(scope.evaluateAccess).toHaveBeenCalledWith({
|
|
||||||
context: FEDERATION_CONTEXT,
|
|
||||||
resource: 'tasks',
|
|
||||||
requestedLimit: 10,
|
|
||||||
nativeRbac: query,
|
|
||||||
});
|
|
||||||
expect(query.list).toHaveBeenCalledWith({ filter: TASK_FILTER, cursor: undefined });
|
|
||||||
expect(response).toEqual({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Federated task',
|
|
||||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
|
||||||
_source: 'local',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves pagination metadata when row cap truncates the query layer result', async () => {
|
|
||||||
const { controller } = makeController({
|
|
||||||
queryResult: {
|
|
||||||
items: [{ id: 'task-1' }],
|
|
||||||
nextCursor: 'cursor-2',
|
|
||||||
truncated: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await controller.list('tasks', makeRequest(), { cursor: 'cursor-1' });
|
|
||||||
|
|
||||||
expect(response).toEqual({
|
|
||||||
items: [{ id: 'task-1', _source: 'local' }],
|
|
||||||
nextCursor: 'cursor-2',
|
|
||||||
_truncated: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a federation error envelope when auth guard context is missing', async () => {
|
|
||||||
const { controller, scope, query } = makeController();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.list('tasks', {} as unknown as FastifyRequest, {}),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
response: {
|
|
||||||
error: {
|
|
||||||
code: 'unauthorized',
|
|
||||||
message: 'Federation context missing',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 401,
|
|
||||||
});
|
|
||||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
|
||||||
expect(query.list).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a federation error envelope when scope evaluation denies access', async () => {
|
|
||||||
const { controller, query } = makeController({
|
|
||||||
scopeResult: {
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'resource_excluded',
|
|
||||||
stage: 'resource_exclusion',
|
|
||||||
statusCode: 403,
|
|
||||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
resource: 'credentials',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(controller.list('credentials', makeRequest(), {})).rejects.toMatchObject({
|
|
||||||
response: {
|
|
||||||
error: {
|
|
||||||
code: 'scope_violation',
|
|
||||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
expect(query.list).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects malformed request body fields before querying storage', async () => {
|
|
||||||
const { controller, scope, query } = makeController();
|
|
||||||
|
|
||||||
await expect(controller.list('tasks', makeRequest(), { cursor: 123 })).rejects.toMatchObject({
|
|
||||||
response: { error: { code: 'invalid_request' } },
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
await expect(controller.list('tasks', makeRequest(), { limit: false })).rejects.toMatchObject({
|
|
||||||
response: { error: { code: 'invalid_request' } },
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
await expect(controller.list('tasks', makeRequest(), { limit: 'abc' })).rejects.toMatchObject({
|
|
||||||
response: { error: { code: 'invalid_request' } },
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
|
||||||
expect(query.list).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation list query layer (FED-M3-05).
|
|
||||||
*
|
|
||||||
* Read-only DB adapter used by ListController after FederationAuthGuard and
|
|
||||||
* FederationScopeService have established the subject user, allowed resource,
|
|
||||||
* native-RBAC intersection, and row cap. Audit writes are intentionally
|
|
||||||
* deferred to M4.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
and,
|
|
||||||
desc,
|
|
||||||
eq,
|
|
||||||
inArray,
|
|
||||||
insights,
|
|
||||||
isNotNull,
|
|
||||||
lt,
|
|
||||||
missionTasks,
|
|
||||||
missions,
|
|
||||||
or,
|
|
||||||
preferences,
|
|
||||||
projects,
|
|
||||||
tasks,
|
|
||||||
teamMembers,
|
|
||||||
type Db,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import type {
|
|
||||||
FederationNativeRbacEvaluator,
|
|
||||||
FederationNativeRbacRequest,
|
|
||||||
FederationNativeRbacResult,
|
|
||||||
FederationScopeQueryFilter,
|
|
||||||
} from '../scope.service.js';
|
|
||||||
import { DB } from '../../../database/database.module.js';
|
|
||||||
|
|
||||||
export interface FederationListQueryRequest {
|
|
||||||
readonly filter: FederationScopeQueryFilter;
|
|
||||||
readonly cursor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationListQueryResult<T extends object = Record<string, unknown>> {
|
|
||||||
readonly items: T[];
|
|
||||||
readonly nextCursor?: string;
|
|
||||||
readonly truncated: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type CursorSource = 'insights' | 'preferences';
|
|
||||||
const CURSOR_SOURCE = Symbol('federationCursorSource');
|
|
||||||
|
|
||||||
type RowObject = Record<string, unknown> & { readonly [CURSOR_SOURCE]?: CursorSource };
|
|
||||||
|
|
||||||
interface KeysetCursor {
|
|
||||||
readonly createdAt: Date;
|
|
||||||
readonly id: string;
|
|
||||||
readonly source?: CursorSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeCursor(row: RowObject): string {
|
|
||||||
const createdAt = row['createdAt'];
|
|
||||||
const id = row['id'];
|
|
||||||
if (!(createdAt instanceof Date) || typeof id !== 'string') {
|
|
||||||
throw new Error('Federation list cursor cannot be encoded');
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = row[CURSOR_SOURCE];
|
|
||||||
return Buffer.from(
|
|
||||||
JSON.stringify({ createdAt: createdAt.toISOString(), id, ...(source ? { source } : {}) }),
|
|
||||||
'utf8',
|
|
||||||
).toString('base64url');
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeCursor(cursor: string | undefined): KeysetCursor | undefined {
|
|
||||||
if (cursor === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
|
|
||||||
if (typeof parsed !== 'object' || parsed === null) {
|
|
||||||
throw new Error('cursor must be an object');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { createdAt, id, source } = parsed as {
|
|
||||||
createdAt?: unknown;
|
|
||||||
id?: unknown;
|
|
||||||
source?: unknown;
|
|
||||||
};
|
|
||||||
if (typeof createdAt !== 'string' || typeof id !== 'string' || id.length === 0) {
|
|
||||||
throw new Error('cursor is missing createdAt or id');
|
|
||||||
}
|
|
||||||
if (source !== undefined && source !== 'insights' && source !== 'preferences') {
|
|
||||||
throw new Error('cursor source is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(createdAt);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
throw new Error('cursor createdAt is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { createdAt: date, id, ...(source ? { source } : {}) };
|
|
||||||
} catch {
|
|
||||||
throw new Error('Invalid federation list cursor');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function paginate<T extends RowObject>(rows: T[], limit: number): FederationListQueryResult<T> {
|
|
||||||
const page = rows.slice(0, limit);
|
|
||||||
const hasMore = rows.length > limit;
|
|
||||||
const nextCursor = hasMore ? encodeCursor(page[page.length - 1] ?? {}) : undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: page,
|
|
||||||
truncated: hasMore,
|
|
||||||
...(nextCursor !== undefined ? { nextCursor } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function markCursorSource<T extends RowObject>(row: T, source: CursorSource): T {
|
|
||||||
Object.defineProperty(row, CURSOR_SOURCE, {
|
|
||||||
value: source,
|
|
||||||
enumerable: false,
|
|
||||||
configurable: false,
|
|
||||||
});
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortRows(rows: RowObject[]): RowObject[] {
|
|
||||||
return [...rows].sort((a, b) => {
|
|
||||||
const aTime = a['createdAt'] instanceof Date ? a['createdAt'].getTime() : 0;
|
|
||||||
const bTime = b['createdAt'] instanceof Date ? b['createdAt'].getTime() : 0;
|
|
||||||
if (aTime !== bTime) {
|
|
||||||
return bTime - aTime;
|
|
||||||
}
|
|
||||||
return String(b['id'] ?? '').localeCompare(String(a['id'] ?? ''));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FederationListQueryService implements FederationNativeRbacEvaluator {
|
|
||||||
constructor(@Inject(DB) private readonly db: Db) {}
|
|
||||||
|
|
||||||
async evaluateReadAccess(
|
|
||||||
request: FederationNativeRbacRequest,
|
|
||||||
): Promise<FederationNativeRbacResult> {
|
|
||||||
if (request.resource === 'credentials' || request.resource === 'api_keys') {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: `${request.resource} federation list access is not implemented in M3`,
|
|
||||||
details: { resource: request.resource },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.resource === 'memory') {
|
|
||||||
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
|
|
||||||
return { allowed: true, access: { includePersonal: true, teamIds } };
|
|
||||||
}
|
|
||||||
|
|
||||||
async list<T extends RowObject = RowObject>(
|
|
||||||
request: FederationListQueryRequest,
|
|
||||||
): Promise<FederationListQueryResult<T>> {
|
|
||||||
const cursor = decodeCursor(request.cursor);
|
|
||||||
const rows = await this.listAllRows(request.filter, request.filter.limit + 1, cursor);
|
|
||||||
return paginate(rows as T[], request.filter.limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listAllRows(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
rowLimit: number,
|
|
||||||
cursor: KeysetCursor | undefined,
|
|
||||||
): Promise<RowObject[]> {
|
|
||||||
switch (filter.resource) {
|
|
||||||
case 'tasks':
|
|
||||||
return this.listTasks(filter, rowLimit, cursor);
|
|
||||||
case 'notes':
|
|
||||||
return this.listNotes(filter, rowLimit, cursor);
|
|
||||||
case 'memory':
|
|
||||||
return this.listMemory(filter, rowLimit, cursor);
|
|
||||||
case 'credentials':
|
|
||||||
case 'api_keys':
|
|
||||||
return [];
|
|
||||||
default:
|
|
||||||
throw new Error(`Unsupported federation list resource: ${String(filter.resource)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
|
|
||||||
const rows = await this.db
|
|
||||||
.select({ teamId: teamMembers.teamId })
|
|
||||||
.from(teamMembers)
|
|
||||||
.where(eq(teamMembers.userId, subjectUserId));
|
|
||||||
|
|
||||||
return rows.map((row) => row.teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
|
|
||||||
const clauses = [];
|
|
||||||
if (filter.includePersonal) {
|
|
||||||
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
|
|
||||||
}
|
|
||||||
if (filter.teamIds.length > 0) {
|
|
||||||
clauses.push(
|
|
||||||
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clauses.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.select({ id: projects.id })
|
|
||||||
.from(projects)
|
|
||||||
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
|
|
||||||
|
|
||||||
return rows.map((row) => row.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
|
|
||||||
if (projectIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.select({ id: missions.id })
|
|
||||||
.from(missions)
|
|
||||||
.where(inArray(missions.projectId, [...projectIds]));
|
|
||||||
|
|
||||||
return rows.map((row) => row.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listTasks(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
rowLimit: number,
|
|
||||||
cursor: KeysetCursor | undefined,
|
|
||||||
): Promise<RowObject[]> {
|
|
||||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
|
||||||
const missionIds = await this.listMissionIds(projectIds);
|
|
||||||
const clauses = [];
|
|
||||||
|
|
||||||
if (projectIds.length > 0) {
|
|
||||||
clauses.push(inArray(tasks.projectId, projectIds));
|
|
||||||
}
|
|
||||||
if (missionIds.length > 0) {
|
|
||||||
clauses.push(inArray(tasks.missionId, missionIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clauses.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopeClause = clauses.length === 1 ? clauses[0] : or(...clauses);
|
|
||||||
const cursorClause = cursor
|
|
||||||
? or(
|
|
||||||
lt(tasks.createdAt, cursor.createdAt),
|
|
||||||
and(eq(tasks.createdAt, cursor.createdAt), lt(tasks.id, cursor.id)),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.select({
|
|
||||||
id: tasks.id,
|
|
||||||
title: tasks.title,
|
|
||||||
description: tasks.description,
|
|
||||||
status: tasks.status,
|
|
||||||
priority: tasks.priority,
|
|
||||||
projectId: tasks.projectId,
|
|
||||||
missionId: tasks.missionId,
|
|
||||||
assignee: tasks.assignee,
|
|
||||||
tags: tasks.tags,
|
|
||||||
dueDate: tasks.dueDate,
|
|
||||||
metadata: tasks.metadata,
|
|
||||||
createdAt: tasks.createdAt,
|
|
||||||
updatedAt: tasks.updatedAt,
|
|
||||||
})
|
|
||||||
.from(tasks)
|
|
||||||
.where(and(scopeClause, cursorClause))
|
|
||||||
.orderBy(desc(tasks.createdAt), desc(tasks.id))
|
|
||||||
.limit(rowLimit);
|
|
||||||
|
|
||||||
return sortRows(rows as RowObject[]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listNotes(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
rowLimit: number,
|
|
||||||
cursor: KeysetCursor | undefined,
|
|
||||||
): Promise<RowObject[]> {
|
|
||||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
|
||||||
const missionIds = await this.listMissionIds(projectIds);
|
|
||||||
|
|
||||||
if (missionIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// mission_tasks rows are user-scoped even when the mission belongs to a team.
|
|
||||||
// Team visibility can narrow the mission set, but it must never widen the
|
|
||||||
// query to other users' mission task notes.
|
|
||||||
const scopeClause = and(
|
|
||||||
eq(missionTasks.userId, filter.subjectUserId),
|
|
||||||
inArray(missionTasks.missionId, missionIds),
|
|
||||||
);
|
|
||||||
const cursorClause = cursor
|
|
||||||
? or(
|
|
||||||
lt(missionTasks.createdAt, cursor.createdAt),
|
|
||||||
and(eq(missionTasks.createdAt, cursor.createdAt), lt(missionTasks.id, cursor.id)),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.select({
|
|
||||||
id: missionTasks.id,
|
|
||||||
missionId: missionTasks.missionId,
|
|
||||||
taskId: missionTasks.taskId,
|
|
||||||
status: missionTasks.status,
|
|
||||||
content: missionTasks.notes,
|
|
||||||
createdAt: missionTasks.createdAt,
|
|
||||||
updatedAt: missionTasks.updatedAt,
|
|
||||||
})
|
|
||||||
.from(missionTasks)
|
|
||||||
.where(and(scopeClause, cursorClause, isNotNull(missionTasks.notes)))
|
|
||||||
.orderBy(desc(missionTasks.createdAt), desc(missionTasks.id))
|
|
||||||
.limit(rowLimit);
|
|
||||||
|
|
||||||
return sortRows(rows.filter((row) => row.content !== '') as RowObject[]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listMemory(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
rowLimit: number,
|
|
||||||
cursor: KeysetCursor | undefined,
|
|
||||||
): Promise<RowObject[]> {
|
|
||||||
if (!filter.includePersonal) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (cursor && cursor.source === undefined) {
|
|
||||||
throw new Error('Invalid federation list cursor');
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows: RowObject[] = [];
|
|
||||||
|
|
||||||
// Memory spans two physical tables. To keep pagination deterministic and
|
|
||||||
// resumable without a SQL UNION, M3 emits a fixed block order: all insights
|
|
||||||
// first, then preferences. The opaque cursor records which table produced
|
|
||||||
// the boundary row, so the next page never re-applies one table's keyset to
|
|
||||||
// the other table (which could duplicate/skip rows at equal timestamps).
|
|
||||||
if (cursor?.source !== 'preferences') {
|
|
||||||
const insightCursorClause = cursor
|
|
||||||
? or(
|
|
||||||
lt(insights.createdAt, cursor.createdAt),
|
|
||||||
and(eq(insights.createdAt, cursor.createdAt), lt(insights.id, cursor.id)),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
const insightRows = await this.db
|
|
||||||
.select({
|
|
||||||
id: insights.id,
|
|
||||||
kind: insights.source,
|
|
||||||
content: insights.content,
|
|
||||||
category: insights.category,
|
|
||||||
relevanceScore: insights.relevanceScore,
|
|
||||||
metadata: insights.metadata,
|
|
||||||
createdAt: insights.createdAt,
|
|
||||||
updatedAt: insights.updatedAt,
|
|
||||||
})
|
|
||||||
.from(insights)
|
|
||||||
.where(and(eq(insights.userId, filter.subjectUserId), insightCursorClause))
|
|
||||||
.orderBy(desc(insights.createdAt), desc(insights.id))
|
|
||||||
.limit(rowLimit);
|
|
||||||
|
|
||||||
rows.push(...(insightRows as RowObject[]).map((row) => markCursorSource(row, 'insights')));
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = rowLimit - rows.length;
|
|
||||||
if (remaining <= 0) {
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferenceCursorClause =
|
|
||||||
cursor?.source === 'preferences'
|
|
||||||
? or(
|
|
||||||
lt(preferences.createdAt, cursor.createdAt),
|
|
||||||
and(eq(preferences.createdAt, cursor.createdAt), lt(preferences.id, cursor.id)),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
const preferenceRows = await this.db
|
|
||||||
.select({
|
|
||||||
id: preferences.id,
|
|
||||||
kind: preferences.category,
|
|
||||||
key: preferences.key,
|
|
||||||
value: preferences.value,
|
|
||||||
source: preferences.source,
|
|
||||||
mutable: preferences.mutable,
|
|
||||||
createdAt: preferences.createdAt,
|
|
||||||
updatedAt: preferences.updatedAt,
|
|
||||||
})
|
|
||||||
.from(preferences)
|
|
||||||
.where(and(eq(preferences.userId, filter.subjectUserId), preferenceCursorClause))
|
|
||||||
.orderBy(desc(preferences.createdAt), desc(preferences.id))
|
|
||||||
.limit(remaining);
|
|
||||||
|
|
||||||
rows.push(
|
|
||||||
...(preferenceRows as RowObject[]).map((row) => markCursorSource(row, 'preferences')),
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation list verb (FED-M3-05).
|
|
||||||
*
|
|
||||||
* POST /api/federation/v1/list/:resource
|
|
||||||
*
|
|
||||||
* Pipeline: FederationAuthGuard attaches the active grant context, then
|
|
||||||
* FederationScopeService enforces grant scope + native RBAC intersection, then
|
|
||||||
* the read-only query layer returns capped rows tagged with `_source`. Read
|
|
||||||
* audit-log writes are deferred to M4; this controller does not persist request
|
|
||||||
* or response bodies.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
Req,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
import {
|
|
||||||
FederationInvalidRequestError,
|
|
||||||
FederationScopeViolationError,
|
|
||||||
FederationUnauthorizedError,
|
|
||||||
SOURCE_LOCAL,
|
|
||||||
tagWithSource,
|
|
||||||
type FederationListResponse,
|
|
||||||
type SourceTag,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
|
||||||
import '../federation-context.js';
|
|
||||||
import { FederationScopeService } from '../scope.service.js';
|
|
||||||
import { FederationListQueryService } from './list-query.service.js';
|
|
||||||
|
|
||||||
interface FederationListRequestBody {
|
|
||||||
readonly limit?: unknown;
|
|
||||||
readonly cursor?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FederatedRow = Record<string, unknown> & SourceTag;
|
|
||||||
|
|
||||||
function parseLimit(body: FederationListRequestBody | undefined): number | undefined {
|
|
||||||
if (body?.limit === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed =
|
|
||||||
typeof body.limit === 'number'
|
|
||||||
? body.limit
|
|
||||||
: typeof body.limit === 'string' && body.limit.trim().length > 0
|
|
||||||
? Number(body.limit)
|
|
||||||
: Number.NaN;
|
|
||||||
|
|
||||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationInvalidRequestError(
|
|
||||||
'Federation list limit must be a positive integer',
|
|
||||||
).toEnvelope(),
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCursor(body: FederationListRequestBody | undefined): string | undefined {
|
|
||||||
if (body?.cursor === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (typeof body.cursor === 'string') {
|
|
||||||
return body.cursor;
|
|
||||||
}
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationInvalidRequestError('Federation list cursor must be a string').toEnvelope(),
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('api/federation/v1/list')
|
|
||||||
@UseGuards(FederationAuthGuard)
|
|
||||||
export class ListController {
|
|
||||||
constructor(
|
|
||||||
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
|
|
||||||
@Inject(FederationListQueryService) private readonly query: FederationListQueryService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Post(':resource')
|
|
||||||
async list(
|
|
||||||
@Param('resource') resource: string,
|
|
||||||
@Req() request: FastifyRequest,
|
|
||||||
@Body() body?: FederationListRequestBody,
|
|
||||||
): Promise<FederationListResponse<FederatedRow>> {
|
|
||||||
if (!request.federationContext) {
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestedLimit = parseLimit(body);
|
|
||||||
const cursor = parseCursor(body);
|
|
||||||
const scopeResult = await this.scope.evaluateAccess({
|
|
||||||
context: request.federationContext,
|
|
||||||
resource,
|
|
||||||
requestedLimit,
|
|
||||||
nativeRbac: this.query,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!scopeResult.allowed) {
|
|
||||||
const ErrorClass =
|
|
||||||
scopeResult.deny.statusCode === 400
|
|
||||||
? FederationInvalidRequestError
|
|
||||||
: FederationScopeViolationError;
|
|
||||||
throw new HttpException(
|
|
||||||
new ErrorClass(scopeResult.deny.message, scopeResult.deny).toEnvelope(),
|
|
||||||
scopeResult.deny.statusCode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: Awaited<ReturnType<FederationListQueryService['list']>>;
|
|
||||||
try {
|
|
||||||
result = await this.query.list({ filter: scopeResult.filter, cursor });
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof Error && error.message === 'Invalid federation list cursor') {
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationInvalidRequestError('Federation list cursor is invalid').toEnvelope(),
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response: FederationListResponse<FederatedRow> = {
|
|
||||||
items: tagWithSource(result.items, SOURCE_LOCAL),
|
|
||||||
};
|
|
||||||
if (result.nextCursor !== undefined) {
|
|
||||||
response.nextCursor = result.nextCursor;
|
|
||||||
}
|
|
||||||
if (result.truncated) {
|
|
||||||
response._truncated = true;
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# Debate Findings & Dispositions — Mosaic Platform PRD
|
|
||||||
|
|
||||||
> **Convener:** mos-claude-1 · **Date:** 2026-07-09 · **Panel:** 9 personas × 2 rounds (8 Claude lenses + independent Codex runtime), 20 agents, ~1.05M tokens
|
|
||||||
> **Artifacts:** `jarvis-brain:docs/scratchpads/mosaic-platform-prd-debate/` (THREAD.md — full transcript · SYNTHESIS.md — Principal-Engineer close-out)
|
|
||||||
> **Mandate (Jason, 2026-07-09):** "debate and make judgment calls." D1–D12 were held fixed; the panel attacked only the implementing structure. Dispositions below are the convener's judgment calls, folded into the sibling docs in this directory. Items marked **OPEN — Jason** need his call at ratification.
|
|
||||||
|
|
||||||
## How to read this
|
|
||||||
|
|
||||||
Every synthesis finding (SYNTHESIS.md §1, items 1–24) is dispositioned here. **Accepted** findings are folded into the PRDs/YAML as inline fixes or "Debate-accepted deltas" rows; this file is the traceability record. Severity labels are the panel's (P0 blocker → P3 note).
|
|
||||||
|
|
||||||
## P0 findings — all accepted, folded inline
|
|
||||||
|
|
||||||
| # | Finding | Disposition |
|
|
||||||
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| 1 | Storage-authority contradiction (X2 "relational + flat-file backends" vs Q "Postgres sole record") | **Accepted.** X2 rewritten: Postgres authoritative for all product entities; flat-file backend is a derived, regenerated, **read-only projection**. |
|
|
||||||
| 2 | Phase-1 Jarvis executes external PA ops before the relay exists (J2 vs NS-11) | **Accepted.** J2 split: **J2a** workspace-internal entities (phase 1) / **J2b** external integrations (phase 2, `depends_on: [P2]`). Phase-1 has no external credential path at all. |
|
|
||||||
| 3 | `phase` vs `depends_on` disagree; dispatcher obedience undefined | **Accepted.** Phases encoded as real DAG edges (`X2 depends_on [J2a, P2]`, J2b gate above); sandbox dispatch-test added to ratification checklist (README Gate Zero §). |
|
|
||||||
| 4 | Four load-bearing upstream artifacts unverified (memory subsystem, Hermes-MCP tool equivalents, push pipeline, wake/event router) | **Accepted.** README gains **Gate Zero** (pre-ratification artifact audit); presumed-MISSING rows get goal cards now: **M1** (memory subsystem), **J6** (event/wake router), **K3** (push pipeline). Parity map's MCP row re-pointed at concrete deliverables. |
|
|
||||||
| 5 | X-R4 migration manifest wrong about its own source tree (phantom `tickets.json`, unlisted dirs, six divergent memory stores, live Vikunja sync unmapped) | **Accepted.** X-R4 rewritten: migrator stage 1 = machine-generated source census (incl. untracked paths + all memory stores), per-path disposition, any `unknown` blocks; re-point list generated from `tools/sync_*.py`; Vikunja disposition line added to X-R6. |
|
|
||||||
|
|
||||||
## P1 findings — all accepted
|
|
||||||
|
|
||||||
| # | Finding | Disposition (folded as deltas) |
|
|
||||||
| --- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| 6 | Events have no provider join key; re-point duplicates the calendar | **Accepted.** Provider-sourced events migrate **from the provider**; flat files supply only brain-native events. DST/recurrence fixture is the AC (X-R6 delta). |
|
|
||||||
| 7 | Approval pipeline has no state machine (double-execute / approve≠execute) | **Accepted.** P deltas: CAS on one durable row, terminal states, consumed-event dedupe table (shared substrate with bridge + wake dedupe), payload persisted at request time, poll/ack outcomes, per-capability retry class, staleness bound, `approved_unexecuted` alarm, re-surface = re-**prepare** with machine diff. |
|
|
||||||
| 8 | "Cannot bypass by construction" false: agent-readable vault creds + host-resident creds outside the relay | **Accepted.** Credentials gateway-only + scoped capability tokens; P1 gains host credential inventory with continuous scheduled scan in the health floor; clean-host AC passes with empty exemption list. |
|
|
||||||
| 9 | Silent auto-deny steady state (fail-closed TTL + single surface + unprovisioned push) | **Accepted.** K gains homeserver ops + monitored push (K3); CLI approval surface ships **with** P2; delivered/seen tracking + TTL/2 escalation; `denied` / `expired_seen` / `expired_unseen` distinct terminal states. |
|
|
||||||
| 10 | Prompt injection → durable memory; wake turns add system-role injection | **Accepted.** J2 write-side trust rule (transitive `source_trust=external`; standing-instruction-shaped content needs user ratification before retrievable); wake turns templated with provenance-tagged data fields. |
|
|
||||||
| 11 | Exactly-one-Jarvis has no fencing incl. the Matrix send path | **Accepted.** New **J-R16** workspace lease `(workspace_id, epoch)` CAS row in product Postgres; epoch on every write; pre-send lease re-check; takeover notice; degraded = mute-with-notice. NS-10 amended: one main agent per **workspace**. |
|
|
||||||
| 12 | Matrix principal resolution undefined (Codex #2, unanswered in R2) | **Accepted.** K/P delta: immutable Matrix user ID + bridge provenance + workspace membership → product principal; unlinked/bridged-unlinked identities read-only, cannot approve/butt-in/trigger external writes. All four Codex fixtures = deny + audit. |
|
|
||||||
| 13 | Policy evaluation time undefined (Codex #7, unanswered in R2) | **Accepted.** Immutable policy snapshot on every prepared action/card; execution revalidates or fails `policy_changed`; delegated effects gated by grant **intersection**. |
|
|
||||||
|
|
||||||
## P2/P3 findings — accepted (see per-PRD delta sections)
|
|
||||||
|
|
||||||
14 Hermes evidence snapshot **before** stop (machine gate) · 15 Q1 crash barriers + `external_refs` unique-index table (one mechanism, three consumers) · 16 rollback honestly scoped (transport-only, point-of-no-return = first native card) + bounded day-30 review with three recorded outcomes · 17 human-attention budget (rate limits, quotas, deferrable flag, away state) · 18 self-referential-loop containment (provenance labels, source-grounded retrieval preference, retrieval eval gate ≥50 queries / ≥90% baseline recall@5 + negative queries, tombstones, priority budget, day-1 trend telemetry) · 19 butt-in exclusive lease + structured control-plane API + break-glass doctrine · 20 default-closed capability gating with `unclassified` third state · 21 per-agent atomic approval-routing cutover table · 22 `needs-decision` card lifecycle (immutable spec + typed amendments; `ratified_by` authorizes dispatch, never merge) · 23 retention class per durable table; dedupe pruning checkpoint-coupled · 24 AC-NS-8 made measurable (distinct quota pools pinned in profile; TTFT p95 ≤ 1.2× baseline, ≥30 interleaved turns).
|
|
||||||
|
|
||||||
**All accepted.** None conflicts with D1–D12.
|
|
||||||
|
|
||||||
## Open disagreements — convener judgment calls
|
|
||||||
|
|
||||||
| § | Question | Call |
|
|
||||||
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| 3.1 | Gate architecture: tiered blocking pack (Moonshot+Ops+ML-Cal) vs 4 artifact-existence machine gates + pass/fail-free pre-registered dossier (Contrarian) | **Adopted the agreed floor now** (4 machine gates on irreversible transitions, pre-registration, priced amendments, day-1 emission — folded into X). Superstructure choice is **OPEN — Jason**. Convener recommendation: **Contrarian's dossier + dumb gates** — the tier demonstrably gamed itself within one debate round (13+ claims vs cap 10, slot-riding, zero demotions); simple machine gates don't degrade under pressure. |
|
|
||||||
| 3.2 | Audit schema: additive-only typed schema (Coder-Data) vs six-field mandatory envelope + typed payloads + pinned checked-in queries (Contrarian) | **Adopted: minimal envelope + schema-on-read** (folded into P1 delta). Rationale: preserves "can't add data later" essentials without a god-schema by committee; an additive typed layer can be grown later where query pain proves it. |
|
|
||||||
| 3.3 | Unclassified capability: reject at call time vs version-scoped activation hold vs capability-scoped hold | **Adopted: capability-scoped hold** (Contrarian R2#6a, synthesis editor concurs) — gate the capability, ship the version; security patches are never pinned behind classification. |
|
|
||||||
| 3.4 | Away mode: fail-closed expiry labeled `expired_during_away` vs suppress preparation while away | **Adopted: suppress preparation** of non-deferrable requests while away + audited suppressed-preparations list swept on return. Cleaner than labeling corpses; nothing expires that was never surfaced. |
|
|
||||||
| 3.5 | Memory-exclusion scope for measurement artifacts | **Adopted: normative/parametric split** (Contrarian R2#4) — rules the agent is scored against stay retrievable; thresholds/seeds/drill timings/canary templates are excluded. |
|
|
||||||
| 3.6 | Statistical instruments | **Adopted:** deterministic named crash barriers as the gate; residual randomized soak is **trace-directed**, not wall-clock-uniform. |
|
|
||||||
| 3.7 | K2 scope narrowing by Hermes traffic audit | **Adopted as a Gate Zero action:** run the audit pre-ratification; platforms with live traffic become the must-have subset gating X3. |
|
|
||||||
|
|
||||||
## Process rules adopted (README)
|
|
||||||
|
|
||||||
- **Gate Zero** — pre-ratification upstream-artifact audit (`present @ SHA` or MISSING → goal card).
|
|
||||||
- **Conflict register** — spec contradictions block the requirement, not the mission ("alert, don't auto-resolve" promoted to specs).
|
|
||||||
- **DoD line** on every goal card (runbook, health-floor alerts, AGENTS.md).
|
|
||||||
- **Silent-roster rule** — a panel member's silent round records their open findings as open items, never consensus (Codex's R2 silence on #2/#7 is the instance; both were folded as P1 items 12–13 above, explicitly not consensus-resolved).
|
|
||||||
|
|
||||||
## Addendum — logging & telemetry (Jason, 2026-07-09, post-debate)
|
|
||||||
|
|
||||||
Requirement raised outside the panel, folded in the same pass: Mosaic Stack must support **inbound error reporting/logging** from agents and installs, plus **optional anonymous agentic-efficiency telemetry** — no IP or PII capture, opt-in. The P0 of this capability already exists: **MALS** (Mosaic Agent Log System, FastAPI+Postgres, `~/src/mals`), currently dark because its k3s migration landed without an Ingress (tracked: infrastructure #135). Folded as new **workstream L** (L1 restore MALS · L2 Mosaic-native ingestion · L3 anonymous telemetry) + standing objective **NS-14**. Day-1 trial-metric emission (finding 18) targets MALS until W3 panels exist.
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# PRD — Backlog Provider Sync Adapters · Workstream Q
|
|
||||||
|
|
||||||
> **Status:** DRAFT for ratification · **Goals:** Q1–Q3 · **Doctrine:** NS-12 (ratified D3)
|
|
||||||
> **Debate pass 2026-07-09:** panel findings folded — see `DEBATE-FINDINGS.md`.
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
Users choose where they _see and touch_ work — Gitea, GitHub, a local kanban — while the **Mosaic Backlog on native Postgres stays the sole record and dispatch engine** (upholds ASM-1; NS-3/NS-4/NS-5 guarantees never depend on an external provider). Providers attach as bidirectional sync adapters.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Adapter interface + Gitea (Q1)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Q-R1 | A `BacklogProviderAdapter` interface: map card ⇄ external item (create/update/close/comment/label), with stable external-id linkage stored on the card. |
|
|
||||||
| Q-R2 | Sync is bidirectional and conflict-safe: native record wins on divergence; external edits arrive as proposed mutations (applied if non-conflicting, else surfaced). |
|
|
||||||
| Q-R3 | Claims, TTLs, depends_on DAG, and dispatch state live **only** in the native record; adapters project them (e.g. as labels/comments) but never own them. |
|
|
||||||
| Q-R4 | Gitea adapter first (webhook + API), configured per workspace: repo mapping, label conventions, direction (mirror-out / mirror-in / full). |
|
|
||||||
| Q-R5 | Adapter enable/disable is workspace configuration; zero adapters is a fully supported mode. |
|
|
||||||
|
|
||||||
### GitHub (Q2)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Q-R6 | Same interface, GitHub Issues backend. Existing `packages/cli-tools` platform detection informs but does not implement this (that is dev tooling, not product runtime). |
|
|
||||||
|
|
||||||
### Local kanban (Q3)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Q-R7 | A webUI kanban board over the native backlog (no external provider needed) — the "local kanban" choice. Builds on W3's card views and/or the existing `KanbanBoard` component upgraded from demo-grade to live data. |
|
|
||||||
|
|
||||||
## Debate-accepted deltas (2026-07-09) — normative
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Q-R8 | **External-ref linkage is one shared mechanism:** a unique-indexed `external_refs(entity_id, system, external_id)` table serves adapter linkage (Q-R1), migration idempotency (X-R4 anti-join), and re-point verification (X-R6) — built once, three consumers. Idempotent upsert; a violated unique index is a converge signal, never an overwrite. |
|
|
||||||
| Q-R9 | **Crash-safe external creates:** the adapter writes a `pending-link` row **before** any external create and embeds a deterministic card-id marker in the created item, so a crash between create and link-back is recovered by scan, never by duplicate creation. |
|
|
||||||
| Q-R10 | **Echo-loop guard:** both directions carry revision counters; adapter-authored external edits are tagged (marker/actor) and skipped on read-back. A sync cycle that would re-import its own write is a hard test failure. |
|
|
||||||
| Q-R11 | The sync engine is a **level-triggered reconciler** over desired-vs-observed state (same doctrine as J6), not a webhook-only event chase: webhooks accelerate convergence, the reconciler guarantees it. Missed webhooks are a latency event, not a correctness event. |
|
|
||||||
| Q-R12 | Card spec immutability (J-R20) projects cleanly: the mirrored issue body is the pinned spec revision; amendments append as **ordered provider comments**, never body rewrites, so external watchers see the same amendment history as the native record. |
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
1. A card created by Jarvis (J3) appears as a Gitea issue within one sync interval; closing the issue in Gitea marks the card for review, not silent closure; dispatch/claims never round-trip through Gitea.
|
|
||||||
2. Killing the adapter mid-mission: dispatch continues unaffected (record is native); on restart, sync converges without duplicates.
|
|
||||||
3. The same mission can be mirrored to Gitea and viewed on the local kanban simultaneously without state divergence.
|
|
||||||
4. **Named crash barriers** at every external-call boundary (`before_external_create`, `after_create_before_link`, `after_link_before_ack`): kill the adapter at each; zero duplicate external items, zero orphaned cards. CI rule: a new external call site without a named barrier + kill test **fails the build**. Residual randomized soak is trace-directed (§3.6 disposition).
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- External provider AS the backlog (vetoed — "truly swappable backends" option declined 2026-07-09).
|
|
||||||
- Two-way sync of claims/TTL semantics (external systems can't express them; projection only).
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- ASSUMPTION: the delivery fleet's _engineering_ PR/issue flow on the stack repo itself continues to use `cli-tools`/Gitea directly — workstream Q is the product feature for user workspaces, not a replacement for the dev workflow.
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# PRD — Hermes Decommission & Tenant-1 Migration · Workstream X
|
|
||||||
|
|
||||||
> **Status:** DRAFT for ratification · **Goals:** X1–X3 · **Doctrine:** NS-13, ASM-8 (Hermes untouched until verified parity)
|
|
||||||
> Ratified direction (D2, 2026-07-09): Mosaic absorbs **all four** Hermes functions — messaging bridge, task board, permission relay, multi-platform reach.
|
|
||||||
> **Debate pass 2026-07-09:** panel findings folded — this PRD took the heaviest rewrite (storage authority, migration census, memory-store hygiene, honest rollback scope). See `DEBATE-FINDINGS.md`.
|
|
||||||
|
|
||||||
## Deployment scope (D12/ASM-9)
|
|
||||||
|
|
||||||
The trial runs in the **homelab**. Hermes and the primitive-era stack (`mos-claude.service`, jarvis-brain boards) live in the **USC/web1 environment**, which is untouched during the trial. This workstream therefore lands in two stages: **X-in-homelab** (prove parity where the fleet is native — mainly K/P/Q verification plus tenant-1 migration) and **X-at-USC** (post-trial adoption: apply the parity checklist to web1, migrate Mos-on-web1 to `mosaic-agent@orchestrator`, then decommission Hermes there, with `/src/infrastructure` GitOps updates in the same delivery set).
|
|
||||||
|
|
||||||
**Trial go/no-go (D12/ASM-9 gate):** the homelab→USC promotion is **owner-judgment**, not an automated metric. The stage gate is: _Jason instantiates and operates the split-agent stack in the homelab and is satisfied with its operation._ Only on that explicit sign-off does X-at-USC begin. The capability ACs (AC-NS-8…11) are the evidence Jason weighs; they inform the decision but do not auto-trigger USC deployment.
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
Retire Hermes entirely. Mosaic becomes the platform for transport (Matrix connector), task board (native backlog + webUI/adapters), approvals (permission relay), and multi-platform reach (mautrix bridges). In the same arc, Jason's jarvis-brain flat-file data migrates into the product as **tenant #1**, making the product's PA feature set the dogfooded default.
|
|
||||||
|
|
||||||
## Parity map (what replaces what)
|
|
||||||
|
|
||||||
| Hermes function | Mosaic replacement | Workstream |
|
|
||||||
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
||||||
| Messaging bridge (Discord/Telegram/…) | Matrix connector + mautrix bridges | K1, K2 |
|
|
||||||
| Kanban / task board | Native Mosaic Backlog + webUI board + provider adapters | A\*, Q, W3 |
|
|
||||||
| Permission relay (`permissions_*`) | Guard-rails engine + approval queue (Matrix + webUI) | P1–P3 |
|
|
||||||
| Cross-platform user reach | mautrix bridges (agents speak Matrix only) | K2 |
|
|
||||||
| Hermes MCP tools in agent sessions | **Per-tool equivalence table** (Gate Zero artifact): approvals → P2, board ops → Q1/A\*, messaging → K1 — not a generic "gateway API" gesture | P2, Q1, K1 |
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Parity checklist + cutover plan (X1)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| X-R1 | A written, testable parity checklist per row above; each item verified in production before its Hermes counterpart is disabled. The checklist is seeded from a **pre-trial usage audit**: per-MCP-tool and per-capability call counts over a trailing window, plus an inventory of Hermes-held provider callbacks/webhooks and secrets. Parity rows with `n < 5` real invocations in the window cannot be "verified by traffic" — they get **scheduled drills in weeks 1–3** of the observation window instead of silent green. |
|
|
||||||
| X-R1a | **Approval-routing cutover is per-agent atomic:** a cutover table states, per agent, the single moment its `permissions_*` path flips from Hermes to the P relay. No agent ever has two live approval paths; no approval window where neither path is live. |
|
|
||||||
| X-R2 | Cutover is staged with rollback at every stage; Hermes runs untouched until AC-NS-11 is verified (ASM-8). **Rollback is honestly scoped (debate #16): it restores _transport_ (Hermes services + MCP registrations) — board/approval state created natively during the trial does NOT back-migrate.** The **point of no return is the first native-only card**; the runbook says so explicitly, and the abort path (below) is written before cutover, not during an incident. |
|
|
||||||
| X-R3 | The Matrix charter's live-cutover rules apply: stated window, announce before/after, rollback ready. |
|
|
||||||
|
|
||||||
### Tenant-1 migration (X2)
|
|
||||||
|
|
||||||
**Framing (ratified 2026-07-09, storage authority clarified by debate P0 #1):** jarvis-brain **is the P0 (prototype) Mosaic Stack** — its flat-file data layer is the zeroth implementation of what the product does properly. Migration is therefore _P0 → proper Mosaic Stack_. **Native Postgres is the sole authoritative store for every product entity** (consistent with workstream Q's "sole record" doctrine and NS-3/NS-4/NS-5). A flat-file backend, where offered, is a **derived, regenerated, read-only projection** — the same relationship generated views have to JSON in the P0 today — never a co-equal write target. Two distinct data classes migrate — they are not the same destination and neither is frozen:
|
|
||||||
|
|
||||||
- **(a) PA data** (projects, tasks, events, tickets, knowledge) → product entities in the Jason workspace (Project/Task/Event/KnowledgeEntry), authoritative in Postgres; flat-file export available as a read-only projection.
|
|
||||||
- **(b) Agent memory & operational knowledge** (runbooks, digests, scratchpads, OpenBrain thoughts) → the **enhanced memory subsystem (goal M1: vector DB + memory service)**. This flow stays **live and writable** throughout — it was never Hermes and must not be frozen by the PA cutover.
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| X-R4 | **Stage 1 of the migrator is a machine-generated source census, not a hand-written file list** (debate P0 #5 — the hand list was already wrong about its own tree). The census walks the live repo (tracked + untracked), inventories every data path — `data/**`, all memory stores (`memory/`, `memories/`, `memory_store*`, `memory.md`, `brain.jsonl`, `jarvis.db`, digests), scratchpads, notes, prior-generation artifacts — and assigns each a disposition: `migrate-as-PA(entity type)` / `migrate-as-memory(M1 class)` / `derived-regenerate` / `retire-with-history`. **Any path dispositioned `unknown` blocks the run.** Stage 2 migrates per-disposition, preserving source ids in metadata; idempotency via anti-join on the shared `external_refs` table (Q-R8); named crash barriers at each phase boundary. A **field-map table** covers the full `brain.py` query surface (status, progress, due, priority, domain, notes, staleness) → product entity fields, so AC 2 is checkable field-by-field. |
|
|
||||||
| X-R5 | Dry-run mode with a diffable report **generated from the census** (counts per disposition, per-entity field mapping, unmapped-field list — must be empty or explicitly waived); Jason ratifies the report before the real run (canonical-data gate — this is the one migration step that is his call). |
|
|
||||||
| X-R6 | External sync jobs (GLPI, Google Calendar, ICS, Gmail, **Vikunja — disposition decided here: re-point or retire, not silently dropped**) each get a **written per-integration transition protocol**: freeze flat-file sync job → verify product integration live → re-point → verify → retire old job. **Provider-sourced events migrate FROM the provider, not from flat files** (flat files supply only brain-native events) — the provider join key is authoritative, so re-pointing cannot duplicate the calendar (debate #6). Calendar fixture (Codex): a DST-crossing recurring event with one moved and one cancelled occurrence, plus an all-day event, round-trips with zero duplicates and correct local times. |
|
|
||||||
| X-R7 | Agent memory/operational knowledge (b) is migrated into the M1 memory subsystem **before** any jarvis-brain retirement; the memory write path stays continuously available (no read-only freeze of an active substrate). The census classifies every memory item: `ratified` / `superseded` / `draft` / `rejected` / `debate-artifact` / `protocol-normative` / `protocol-parametric`. **Parametric measurement artifacts (thresholds, seeds, drill timings, canary templates) are excluded from embedding** (§3.5 disposition — rules the agent is scored against stay retrievable; the knobs do not). Superseded/rejected items get **tombstones**, and supersession triggers re-embedding of affected summaries. **Retirement gate: a retrieval eval — ≥50 representative queries, ≥90% of baseline recall@5, plus negative queries (rejected/superseded content must NOT surface) — passes against M1 before any flat-file store goes read-only.** After cutover: write paths to retired stores are killed and a CI lint fails any reintroduction. Only once **both** (a) and (b) are migrated and verified is the jarvis-brain repo retired read-only (history preserved); generated views and brain.py retire. This closes the P0 prototype. |
|
|
||||||
|
|
||||||
### Decommission (X3)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| X-R8 | **Pre-stop evidence snapshot is a machine gate:** before Hermes stops, a checksummed snapshot of its state (board items, pending approvals, bridge registrations, per-tool usage counts) is captured and stored with the trial artifacts. The drill schedule (X-R1) and the credential-revocation report both **cite the snapshot checksum** — no snapshot, no stop. Then: services stopped, disabled, and removed from infra (GitOps: `/src/infrastructure` updated in the same delivery set); credentials revoked — the revocation report cites a **final scan** showing zero live references; MCP registrations removed from agent runtimes. |
|
|
||||||
| X-R9 | **Bounded day-30 review** between stop and removal, pre-registered before cutover (dossier: what will be measured, panels cited, drill results attached — see W-R15/L1 for where the metrics live, `stack_version`-segmented so mid-window upgrades don't blur rates). The review records exactly one of three outcomes: **promote** (remove Hermes), **extend with named blockers** (each blocker a card with an owner), or **abort** (execute the pre-written abort runbook; transport-only rollback per X-R2). "Insufficient data" is a recordable outcome that forces extend — never a shrug into promote. Any in-window regression flips back per X-R2. |
|
|
||||||
|
|
||||||
## Machine gates on irreversible transitions (debate §3.1 agreed floor)
|
|
||||||
|
|
||||||
Four **artifact-existence gates** — dumb, checkable, ungameable — sit on the irreversible transitions. Each is "the artifact exists and passes its check", not a scored rubric:
|
|
||||||
|
|
||||||
1. **Pre-stop snapshot** exists with valid checksum (X-R8) — gates Hermes stop.
|
|
||||||
2. **Census with zero `unknown` rows** exists (X-R4) — gates the PA migration run.
|
|
||||||
3. **Retrieval eval pass record** exists (X-R7) — gates memory-store retirement.
|
|
||||||
4. **All parity rows green-or-drilled** (X-R1: verified by traffic or by scheduled drill; no silent low-n green) — gates Hermes removal at day-30.
|
|
||||||
|
|
||||||
The gate _superstructure_ beyond this floor (tiered blocking pack vs pre-registered dossier) is **OPEN — Jason** at ratification; convener recommendation in `DEBATE-FINDINGS.md` §3.1.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
1. AC-NS-11: with Hermes stopped, no fleet or main-agent capability regresses.
|
|
||||||
2. `python tools/brain.py today`'s information content is fully answerable by Jarvis from the product workspace post-X2, verified field-by-field against the X-R4 field-map table.
|
|
||||||
3. Zero references to Hermes MCP tools in any active agent runtime config after X3.
|
|
||||||
4. The X-R6 calendar fixture (DST-crossing recurrence, moved + cancelled occurrence, all-day event) round-trips with zero duplicates.
|
|
||||||
5. The X-R7 retrieval eval passes against M1 before any memory store goes read-only; negative queries return no superseded/rejected content.
|
|
||||||
6. All four machine gates above have their artifacts on record before their respective transitions execute.
|
|
||||||
|
|
||||||
## Sequencing note
|
|
||||||
|
|
||||||
X depends on the longest chains (K1→K2, P2, Q1, J2a→J2b, **M1** — X2 cannot complete class (b) without the memory subsystem existing). Dependencies are real DAG edges in `north-star-additions.yaml` (`X2 depends_on [J2a, P2, M1]`), not prose phases (debate P0 #3). Expected order of value delivery: J1–J4 (Jarvis on existing transport interim) → K1/J5 (Matrix room) → P2, W1–W3, Q1 in parallel → X1 checklist → X2 migration → K2 bridges → X3 decommission.
|
|
||||||
|
|
||||||
- ASSUMPTION (interim transport): until K1 lands, Jarvis may run against the tmux connector (CLI/`agent send`) rather than standing up any Discord channel — keeps D1 (Matrix-first, no #jarvis Discord) intact.
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# PRD — HMI Main Agent ("Jarvis") · Workstream J
|
|
||||||
|
|
||||||
> **Status:** DRAFT for ratification · **Source of truth once landed:** NORTH_STAR.yaml goals J1–J6
|
|
||||||
> **Depends on upstream:** H2 (system-type profiles), A3a (card lifecycle), B1 (supervisor tick), F4/K1 (Matrix connector)
|
|
||||||
> **Debate pass 2026-07-09:** panel findings folded — see `DEBATE-FINDINGS.md` for dispositions.
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
Every Mosaic **workspace** gets exactly one always-on human-machine-interface agent — default alias **Jarvis**, unit `mosaic-agent@main.service` — that owns the human relationship: conversation, idea development, schedule, email, tasks, knowledge. It delegates all engineering/research/ops work to the orchestrator (**Mos**, `mosaic-agent@orchestrator.service`) through the Mosaic Backlog, and reports fleet status to the user without ever interrupting the orchestrator.
|
|
||||||
|
|
||||||
Jarvis is a **Level-0 orchestrator**: it accomplishes its own work through _delegation and subagents_, never by executing coding/infra tasks itself. PA mutations (tasks/events/knowledge) are direct API calls; everything heavier is either a spawned subagent (research, drafting, analysis) or a backlog card handed to Mos (engineering/infra/fleet). This keeps the main agent's context conversational and light.
|
|
||||||
|
|
||||||
This solves the observed failure mode: a busy orchestrator that can't respond, accumulates conversational context rot, and derails over time. Post-split, the orchestrator's context is execution-only.
|
|
||||||
|
|
||||||
Because Jarvis and Mos are **separate agents with separate model capacity** (D11: Jarvis on Opus, Mos on Fable; independent inference quota), orchestrator load cannot degrade conversational latency — the isolation in AC-NS-8 is a capacity guarantee, not merely a separate process.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Persona & runtime (J1)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| J-R1 | Jarvis is provisioned from the personal-assistant persona baseline via system-type profiles (H2/H3); alias, model tier, host, and channel are profile fields, not code. |
|
|
||||||
| J-R2 | Default model tier **Opus** (ratified D11); the orchestrator's tier is independent. Always-_available_ ≠ always-_billed_: Opus is provisioned 24/7 but cost is per-interaction — an idle Jarvis (no user turn in flight) incurs no model spend, so "always-on" carries no standing token bill. |
|
|
||||||
| J-R3 | Jarvis survives reboot under systemd (`mosaic-agent@main`), participates in the fleet heartbeat protocol, and is counted in the supervisor's health floor. |
|
|
||||||
| J-R4 | Persona customization is update-surviving per H4 (override layer wins on merge). |
|
|
||||||
| J-R16 | **Workspace lease (exactly-one fencing):** Jarvis acquires `workspace_lease(workspace_id, epoch)` — a CAS row in product Postgres — before processing any turn. Every PA write, card, and approval carries the epoch; stale-epoch writes are rejected server-side; the connector re-checks the lease immediately before every outbound send. Takeover posts an in-room/in-channel epoch notice. Degraded mode (lease unobtainable) = **mute-with-notice**, never conversational-while-unfenced. Clean shutdown releases the lease. This primitive also excludes homelab/USC split-brain during adoption. |
|
|
||||||
| J-R17 | Per-agent spend metering with a daily budget alarm for `mosaic-agent@main`; a main-agent crash-loop is a distinct, escalated supervisor condition (not a generic restart count). |
|
|
||||||
| J-R18 | **Resume protocol (promoted from open item):** resume context is reconstructed from authoritative queries (board, heartbeats, workspace entities), with narrative summary layered on top; a session-start divergence check flags contradictions between narrative and authoritative state. Jarvis is never re-instantiated from its own lossy summaries alone. |
|
|
||||||
|
|
||||||
### PA toolchain (J2a workspace-internal · J2b external)
|
|
||||||
|
|
||||||
**Split (debate P0 #2):** phase-1 Jarvis operates only on workspace-internal entities — **no external-write credential path exists** until the permission relay (P2) is live. External integrations arrive in phase 2 as J2b, `depends_on: [J2a, P2]`. Test: in a phase-1 deployment, `email:send` is _impossible_ (no credential provisioned), not merely unapproved.
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| J-R5 | (J2a) Jarvis executes personal-assistant mutations **directly** in the user's workspace via the product API: tasks, events/calendar, knowledge entries, ideas. No delegation for PA ops (ratified D4). |
|
|
||||||
| J-R6 | (J2b) External PA integrations (email, external calendars, helpdesk) are workspace-scoped integrations; **raw credentials are held exclusively by the gateway** — Jarvis receives scoped capability tokens, never provider secrets; actions flagged `requires_approval` route through the permission relay (workstream P). |
|
|
||||||
| J-R7 | Until tenant-1 migration (X2) completes, Jarvis may read/write the jarvis-brain flat files as a transitional adapter; the adapter is deleted after the **last verified X-R6 re-point** (not at a nominal "X2 cutover" date). A per-phase, per-entity-type source-of-truth table in the J1 profile states which store is authoritative at every moment. |
|
|
||||||
| J-R19 | **Write-side trust rule:** externally-sourced content (email bodies, bridged messages, webhook payloads) written into workspace entities or memory inherits `source_trust=external` **transitively through summarization**. Standing-instruction-shaped external content requires explicit user ratification before it becomes retrievable. This closes the injection→durable-memory channel. |
|
|
||||||
|
|
||||||
### Delegation contract (J3)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| J-R8 | The Jarvis→Mos handoff is **only** via Mosaic Backlog cards: goal, acceptance criteria, priority, depends_on, advisory budget. Never via chat messages to the orchestrator. |
|
|
||||||
| J-R9 | Jarvis translates conversation outcomes into card sets; ambiguity is resolved with the user _before_ card creation — the orchestrator receives only decision-complete work. |
|
|
||||||
| J-R10 | Card authorship is attributed (author=main-agent, ratified-by=user where applicable) for audit. |
|
|
||||||
| J-R11 | Authority line: Mos holds all execution and merge authority (NS-4). Jarvis relays the user's GO/NO-GO gates as card state, and never acquires fleet mutation, merge, or dispatch rights. `ratified_by=user` authorizes **dispatch only** — it never substitutes for the reviewer-of-record merge gate. |
|
|
||||||
| J-R20 | Card sets are drafted then **published atomically** with client idempotency keys (no partial card sets on crash). Card spec is **immutable after publish**; changes arrive as typed, ordered amendments with a revision counter; reviewer sign-offs pin the spec revision; scope-expanding amendments re-enter ratification. |
|
|
||||||
| J-R21 | **`needs-decision` lifecycle:** a worker hitting genuine ambiguity sets `needs-decision(question, options)` on the card **with a durable checkpoint** (pushed branch + card note) — resume after days is a re-dispatch, not a context continuation. Jarvis relays the question to the user and writes the answer back as an amendment. This is the sanctioned clarification path; J-R8's no-chat rule stands. |
|
|
||||||
| J-R22 | Jarvis-authored cards draw from a **priority budget** (quota per priority class per window) — priority inflation by the card author degrades the field for the whole fleet and is structurally capped, not policed by review. |
|
|
||||||
|
|
||||||
### Passive observability (J4)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| J-R12 | Jarvis answers "what's the fleet doing" from read-only sources: heartbeat files, `mosaic fleet ps` JSON, backlog card states, CI status. Zero messages to the orchestrator for status. |
|
|
||||||
| J-R13 | Jarvis proactively surfaces to the user: blocked cards, failed CI on user-ratified missions, approval requests pending, budget advisories. (PDA-friendly phrasing per SOUL.md.) Delivered via the **J6 event/wake router** — per-agent polling is forbidden. |
|
|
||||||
| J-R23 | **Event/wake router (J6):** one shared, level-triggered reconciler over durable state (heartbeats, card states, approval queue) wakes Jarvis on state _change_ with hysteretic per-condition suppression (wake once, then only on change or declared backoff; suppression survives restarts). Wake turns are **templated** — fixed instruction frame, workspace data only in delimited, provenance-tagged data fields (closes the injection→system-role channel). Idempotent on source event id via the shared consumed-events table. Router is in the health floor; its cost model and latency bound are stated in the J6 card. Reconciles J-R2: an idle Jarvis costs nothing _because waking is event-driven, not poll-driven_. |
|
|
||||||
|
|
||||||
### Channel (J5)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| J-R14 | **Phase 2 (target channel):** Jarvis's conversation lives in a dedicated Matrix room on the self-hosted homeserver via `OrchestratorConnector(matrix)` (K1 = f4 Phase 2). Matrix-first: no Discord channel is created for Jarvis (ratified D1). |
|
|
||||||
| J-R14a | **Phase 1 (interim channel, ratified):** Jarvis runs on the **tmux/CLI connector** — the f4 Phase-1 default connector. The operator launches the `mosaic-agent@main` tmux session and issues `/remote-control` to grant interactive access; this is the day-one conversation surface. No Discord, no Matrix dependency in Phase 1 (keeps D1 intact and unblocks J1–J4 before K1 lands). |
|
|
||||||
| J-R15 | Multi-platform user reach arrives via mautrix bridges (K2); Jarvis's code path is Matrix-only (from Phase 2 onward). |
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
1. AC-NS-8 **(made measurable)**: distinct credential/quota pools for Jarvis and Mos are pinned in the J1 profile; scripted suite of ≥30 interleaved turns under full orchestrator load; TTFT p95 ≤ 1.2× idle baseline with bootstrap CI; orchestrator receives zero conversational traffic.
|
|
||||||
2. AC-NS-9: a conversationally-agreed mission round-trips (cards → drained → completed → reported by Jarvis) with no chat handoff.
|
|
||||||
3. Kill the orchestrator mid-conversation: Jarvis conversation is unaffected; Jarvis reports the outage from heartbeat state. (Directly exercises the separate-capacity guarantee.)
|
|
||||||
4. `!sys`-equivalent admin verbs work in Jarvis's active channel — the tmux/CLI session in Phase 1, the Matrix room in Phase 2 (status/logs/clear/restart of the main agent).
|
|
||||||
5. **Phase-1 channel:** operator launches the `mosaic-agent@main` tmux session, issues `/remote-control`, and holds a full conversation with Jarvis over CLI with no Matrix/Discord dependency.
|
|
||||||
6. **Fencing:** start a second `mosaic-agent@main` by hand — it fails to acquire the workspace lease, posts a notice, and stays mute; zero duplicate writes or cards reach the workspace (J-R16).
|
|
||||||
7. **Phase-1 credential surface:** audit of a phase-1 install finds no external-provider credential readable by the Jarvis runtime (J2a/J2b split holds by construction).
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- Jarvis executing code/infra changes (that is Mos + fleet).
|
|
||||||
- Horizontal sharding of the main agent (rejected in the Matrix charter: split-brain).
|
|
||||||
- Per-workspace fleets (post-MVP per ASM-6).
|
|
||||||
|
|
||||||
## Open items (for Mos's planner)
|
|
||||||
|
|
||||||
- ~~Context hygiene / resume protocol~~ — promoted to J-R18 by the 2026-07-09 debate pass.
|
|
||||||
- Reconcile the old `apps/api` matrix-bot-sdk workspace bridge with the F4 connector design (one Matrix stack, not two). NOTE (verified 2026-07-09): no matrix dependency remains in `apps/api` on `origin/main` — this item is likely already moot; confirm before K1 build.
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# PRD — Permission Relay · Workstream P
|
|
||||||
|
|
||||||
> **Status:** DRAFT for ratification · **Goals:** P1–P3
|
|
||||||
> **Debate pass 2026-07-09:** panel findings folded — see `DEBATE-FINDINGS.md`. The relay was the panel's densest target; the deltas below are normative.
|
|
||||||
> **Design origin (historical):** `docs/3-architecture/guard-rails-capability-permissions.md` — the "prepare freely, execute with approval" snapshot. **Not present on `origin/main`** (survives only in the stale `/src/mosaic-stack` clone), so its essential model is folded into this PRD below; **this document is the authoritative, self-contained spec for P.**
|
|
||||||
> **Replaces:** Hermes `permissions_list_open` / `permissions_respond` relay (Hermes exit prerequisite, NS-13)
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
A human-in-the-loop approval mechanism for agent actions: any capability listed as `requires_approval` is prepared by the agent, queued, and executed only after an explicit human approve — from the Matrix room or the webUI. Today this exists only as a bare `applyGuardRails()` method; Hermes currently fills the gap and must be replaced before decommission.
|
|
||||||
|
|
||||||
## Design model (folded in — the authoritative spec, since the origin snapshot is off-main)
|
|
||||||
|
|
||||||
**Doctrine — "prepare freely, execute with approval":** an agent may plan, draft, and stage any action without friction; only the _committing_ step of a `requires_approval` capability blocks on a human decision.
|
|
||||||
|
|
||||||
**Permission levels (least→most):** `read` → `organize` → `draft` → `execute` → `admin`. A capability grant names a level; `requires_approval` gates the transition into `execute`/`admin` for the capabilities a workspace marks sensitive.
|
|
||||||
|
|
||||||
**Grant shape:** `resource:action` (e.g. `email:send`, `git:push_main`, `dns:update`), scoped per workspace and per agent-persona, stored as configuration (profile field) so a user tightens/loosens without a code change.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Guard-rails engine (P1)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| P-R1 | Capabilities are `resource:action` grants (e.g. `email:send`, `git:push_main`, `dns:update`) with permission levels (read / organize / draft / execute / admin) per the existing design doc. |
|
|
||||||
| P-R2 | Each integration declares its `requires_approval` list; grants are workspace-scoped and per-agent-persona. |
|
|
||||||
| P-R3 | Enforcement sits in the gateway/API dispatch path — an agent cannot bypass it by construction; bypass attempts are audited and denied. |
|
|
||||||
| P-R4 | Policy is configuration (profile field), honoring the configurability pillar: a user can tighten/loosen per capability without code change. |
|
|
||||||
|
|
||||||
### Approval queue + chat approvals (P2)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| P-R5 | A pending approval is a durable queue record: requesting agent, capability, human-readable intent summary, prepared payload reference, TTL. |
|
|
||||||
| P-R6 | Approve/deny from the Matrix room (message action or reply verb); the requesting agent is notified of the outcome and proceeds/aborts. |
|
|
||||||
| P-R7 | Timeout = deny (fail-closed), with **per-capability TTLs** and distinct terminal states: `denied` / `expired_seen` / `expired_unseen` — agents must not reason about an expiry as a human "no". Deny and timeout leave the system unchanged. |
|
|
||||||
| P-R8 | Full audit trail: who approved what, when, from which surface (AC-NS-10). |
|
|
||||||
| P-R9 | The main agent (Jarvis) surfaces pending approvals conversationally (J-R13) but approval authority is the human's — Jarvis never auto-approves. |
|
|
||||||
|
|
||||||
### webUI surface (P3)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| P-R10 | Pending-approval queue view in `apps/web` with one-click approve/deny, filterable per workspace/agent (depends W3 dashboard shell). |
|
|
||||||
|
|
||||||
## Debate-accepted deltas (2026-07-09) — normative
|
|
||||||
|
|
||||||
### State machine & delivery (extends P-R5–P-R7)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| P-R11 | Approval lifecycle is a **CAS state machine on one durable row**: `pending → approved \| denied \| expired_seen \| expired_unseen`, all terminal. Concurrent surfaces (Matrix, webUI, CLI, TTL reaper) contend via compare-and-swap; exactly one wins. |
|
|
||||||
| P-R12 | The **prepared payload is persisted in the record at request time** — never a reference to requesting-agent process state (the agent may be dead when approval lands). Outcome delivery is **poll/ack**, not push-only; `approved_unexecuted > N min` raises an alarm. |
|
|
||||||
| P-R13 | A **consumed-event dedupe table** (shared substrate with bridged-message and wake-turn dedupe — built once, three consumers) makes approval consumption idempotent under Matrix at-least-once replay. Dedupe retention is **checkpoint-coupled**: events older than the durable sync token are dropped before lookup, so pruning never reopens the replay window. |
|
|
||||||
| P-R14 | Each capability declares `retry: safe \| at-most-once`; a prepare→execute **staleness bound** rejects execution of stale payloads. Re-surfacing an expired item **re-prepares** (new linked record with a rendered machine diff against the original) — never re-queues a stale payload. |
|
|
||||||
| P-R15 | **CLI approval surface** (`mosaic approvals list\|approve\|deny`) ships **with P2** as must-have — the newest infra (Matrix) is never the only approval path. Per-request delivered/seen tracking; TTL/2 escalation via a second path. |
|
|
||||||
|
|
||||||
### Identity & policy (closes Codex #2/#7)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| P-R16 | **Principal resolution:** every inbound approve/deny maps to a product principal via immutable Matrix user ID + bridge provenance + workspace membership. Unlinked identities and bridged puppets without an account link converse read-only and **cannot approve, butt-in, or trigger external writes**. Fixtures: bridged puppet, renamed user, invited non-admin, removed-member-with-lagging-room-membership — all deny + audit with reason. |
|
|
||||||
| P-R17 | **Policy snapshot:** every prepared action and card stores an immutable snapshot (profile id/version, grants evaluated). Execution **revalidates against current policy** or fails with explicit `policy_changed`. Profile changes are audited with schema validation + dry-run impact report. Delegated work (subagent, Jarvis-authored card) executes under the **intersection** of originator and executor grants. |
|
|
||||||
|
|
||||||
### Credential boundary (makes P-R3 true)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| P-R18 | Raw provider credentials are held **exclusively by the gateway**; agents receive scoped capability tokens; the gateway injects secrets server-side. If the agent runtime can read the provider secret, P-R3 is decoration. |
|
|
||||||
| P-R19 | P1 delivers a **fleet-host credential inventory**: every host-resident credential (SSH keys, kubeconfigs, tool tokens) classified `moved-behind-gateway` or `explicitly-exempt(reason, owner, expiry)`. Enforced by a **continuous scheduled scan** of agent-readable paths (alert on unclassified), registered in the health floor. The clean-host install AC passes with an **empty exemption list**. Break-glass is one standing exempt row (owner: operator; audited post-hoc). |
|
|
||||||
|
|
||||||
### Gating defaults & load (closes default-open + human-overdraw)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| P-R20 | **Default-closed inversion:** `execute`/`admin` capabilities on external integrations require approval **unless** workspace-allowlisted. Third state `unclassified`: a capability with no classification **rejects** at the gate. Classification happens at integration-version activation, scoped to the **capability, not the version** — a security patch bundling one new capability ships same-day; only the new capability rejects until classified. Manifest-less capabilities reject + integrity alert. |
|
|
||||||
| P-R21 | Rate limits + `max_pending_per_agent_per_capability`; queue depth and human decision latency are exported metrics with thresholds (rubber-stamping guard). Capabilities carry a `deferrable` flag; a declared **away state** pauses deferrable TTLs, batches pings, and **suppresses preparation** of non-deferrable requests (suppressed list swept, priority-ordered and paginated, on return). |
|
|
||||||
| P-R22 | Approval summaries render **machine-extracted payload facts unconditionally** (recipient, amount, target host — no tunable similarity threshold); agent prose is secondary. **Canary approvals** are gateway-generated, short-circuited at the gateway (never executable), immediately disclosed after decision, timing seeded outside agent-readable stores; the gated criterion is machine-flag correctness — human catch rate is reported with binomial CI, non-blocking. |
|
|
||||||
| P-R23 | Every durable table introduced by P declares a **retention class**, linted in CI; pruning jobs are health-floor registered. Minimal mandatory audit envelope: `ts, workspace, actor, correlation_id, stack_version, schema_version` — payloads are typed free-form with pinned, checked-in queries (debate §3.2 disposition). |
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
1. AC-NS-10 end-to-end: a `requires_approval` action executes only post-approve; deny/timeout paths verified unchanged + audited.
|
|
||||||
2. Approval round-trip from a phone Matrix client (Element) in under 3 taps — **and** the same round-trip via `mosaic approvals` CLI with the homeserver stopped.
|
|
||||||
3. With Hermes stopped, permission flow fully served by Mosaic (feeds AC-NS-11).
|
|
||||||
4. Crash-consistency: kill the gateway at each named barrier (`before_persist`, `after_approve_before_execute`, `after_execute_before_ack`); zero double-executions, zero lost approvals across the suite.
|
|
||||||
5. All four principal-resolution fixtures (P-R16) deny + audit; policy-change race (P-R17) fails `policy_changed`, never executes under stale grants.
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- Automated quality gates (coordinator/CI approvals) — different system, already exists.
|
|
||||||
- Fine-grained LLM output moderation — out of scope; this governs _actions_.
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- ASSUMPTION: the durable queue rides the native Postgres storage service (same substrate as the backlog), not a new datastore.
|
|
||||||
- ASSUMPTION: routine delivery operations already hard-gated as no-confirmation (push/merge per Mosaic contract) are NOT routed through the relay — the relay is for `requires_approval` capabilities only, so it does not reintroduce routine confirmation prompts.
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# PRD — webUI Fleet Control · Workstream W (realizes F6)
|
|
||||||
|
|
||||||
> **Status:** DRAFT for ratification · **Goals:** W1–W3 · **Upstream anchor:** `PRD-fleet-suite.md` Phase F6 ("webUI hooks — stable JSON contract + terminate/attach(butt-in) surface")
|
|
||||||
> Confirmed gap: zero xterm/pty/tmux code in `apps/web` on either the old snapshot or `origin/main`.
|
|
||||||
> **Debate pass 2026-07-09:** panel findings folded — see `DEBATE-FINDINGS.md`.
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
The user can pop in on **any** agentic tmux session from the web, and get a full top-down view of the system — fleet roster, health, work in flight, spend — without touching a terminal. This is the product surface for "user has ability to pop in on any agent session; full top-down view available."
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Attach service (W1)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| W-R1 | A gateway service exposes per-agent session streams over WebSocket: **watch** (read-only pane view, cannot type) and **butt-in** (interactive takeover), mirroring the existing CLI verbs `mosaic agent watch/attach`. |
|
|
||||||
| W-R2 | Authz is workspace-scoped through the product auth stack (BetterAuth/Authentik); watch and butt-in are separate grants; butt-in may be `requires_approval` per workspace policy (workstream P). |
|
|
||||||
| W-R3 | Every attach (watch or butt-in) is audited: who, which agent, when, duration. |
|
|
||||||
| W-R4 | Butt-in visibly flags the session to the agent runtime and other viewers (no silent takeover). |
|
|
||||||
| W-R5 | Contract is stable JSON + streaming frames per F6's "stable JSON contract" requirement, so TUI/CLI and webUI share it. |
|
|
||||||
|
|
||||||
### Web terminal (W2)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | ----------------------------------------------------------------------------------------------------------- |
|
|
||||||
| W-R6 | xterm.js view in `apps/web` wired to W1: session list → click → live pane; toggle watch↔butt-in per grants. |
|
|
||||||
| W-R7 | Reconnect-safe (network blips resume the stream), mobile-usable read-only view. |
|
|
||||||
|
|
||||||
### Top-down dashboard (W3)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| W-R8 | Fleet dashboard: roster with per-agent state (systemd + tmux + heartbeat join, as `fleet ps` provides), current card/task, last activity, drift/boot-enable warnings. |
|
|
||||||
| W-R9 | Work-in-flight view: backlog cards by state with depends_on DAG rendering; advisory spend per card (NS-2/NS-5). |
|
|
||||||
| W-R10 | Operator controls: PAUSE kill-switch (NS-8), per-agent terminate (killswitch service), queue pause/resume — each gated + audited; destructive controls confirm. |
|
|
||||||
| W-R11 | Existing widget framework (`AgentStatusWidget`, `OrchestratorEventsWidget`, SSE proxy routes) is the starting point, upgraded to the fleet contract rather than rebuilt. |
|
|
||||||
|
|
||||||
## Debate-accepted deltas (2026-07-09) — normative
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| W-R12 | **Butt-in is an exclusive lease** with explicit, visible takeover: at most one interactive holder per session at any instant; a second client must take the lease and both parties see the transfer. Input frames are sequenced and deduped so a reconnect never double-sends; a heartbeat/idle timeout closes both the lease and its audit span — audit spans always terminate (extends W-R1/W-R3/W-R4). |
|
|
||||||
| W-R13 | **Structured control plane:** PAUSE, terminate, restart, approve/deny, and queue operations are typed API verbs with RBAC and audit — never bytes typed into a tmux pane. Raw terminal input via butt-in is **rescue-only** and a separately-grantable permission from the control verbs. |
|
|
||||||
| W-R14 | **Break-glass doctrine (documented, not prevented):** SSH + `tmux attach` on the fleet host bypasses W1 and P **by design**; it is inventoried under P-R19, audited post-hoc from host logs, and never treated as a product path. PAUSE additionally has an **on-host file/CLI actuator** so a dead gateway can never lock the operator out of the control that fixes the gateway. |
|
|
||||||
| W-R15 | W3 ships the **trial metric panels** as workspace-scoped product views over product storage: canary machine-flag correctness, approval decision latency, card priority distribution, wake→ack rate, agent-authored memory retrieval fraction, human interaction load, fixed-input probe stability. The X-R9 trial evidence pack cites these panels; until W3 exists, day-1 emission targets MALS (L1). Workspace isolation fixture: workspace-2 admin sees zero workspace-1 rows. |
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
1. From a browser (desktop + phone), the user watches a live coder-agent pane read-only, then butt-ins with the right grant, types a message, detaches; agent session continues; audit log shows both.
|
|
||||||
2. Dashboard reflects an agent crash within one heartbeat interval; PAUSE flip halts dispatch within one tick (AC-NS-5) from the UI.
|
|
||||||
3. A user without butt-in grant can watch but cannot type (enforced server-side).
|
|
||||||
4. Two clients contend for butt-in: exactly one holds the lease at any instant, the takeover is visible to both, and after a forced reconnect the input stream shows zero duplicated or interleaved frames (W-R12).
|
|
||||||
5. With the gateway stopped, the operator PAUSEs the fleet via the on-host actuator; the bypass appears in the post-hoc audit (W-R14).
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- Replacing tmux as the session substrate (tmux remains the transport; web is a view).
|
|
||||||
- Cross-host federation of the dashboard (rides the existing federation workstream later, per upstream note "Phase 5 rides federation").
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- ASSUMPTION: pty bridging terminates at the gateway on the fleet host (web1), not in `apps/web`; Next.js only speaks WebSocket to the gateway.
|
|
||||||
- ASSUMPTION: the jarvis-brain dashboard's node-pty/xterm work (`dashboard/server/terminal.ts`) serves as reference implementation only; code is not ported wholesale into the multi-tenant product without the authz layer above.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# Mosaic Platform PRD — Jarvis HMI + Hermes Decommission (DRAFT for ratification)
|
|
||||||
|
|
||||||
**Date:** 2026-07-09 · **Author:** proto-Jarvis session with Jason · **Status:** Decisions D1–D12 **ratified** (fixed inputs); implementing PRD structure **DRAFT** — refined 2026-07-09 post-review, then stress-tested by a 9-persona × 2-round debate panel the same day; all 24 panel findings dispositioned and folded (see `DEBATE-FINDINGS.md`; one item **OPEN — Jason**: gate superstructure, §3.1)
|
|
||||||
**Target home:** `mosaicstack/stack` → `docs/fleet/` (NORTH_STAR.yaml additions + per-phase PRDs)
|
|
||||||
|
|
||||||
> **For the homelab orchestrator:** D1–D12 below are settled constraints, not open questions — do not reopen them. What is under review is only the _implementation_ (workstreams, goals, sequencing) that realizes them.
|
|
||||||
> **Execution:** hand to the **homelab orchestrator** as orchestrated missions once ratified (D12). Land in `docs/fleet/` from `origin/main` — the `/src/mosaic-stack` clone on web1 is 5 months stale and must not be the base. The USC/web1 environment is out of scope for the trial; its cutover (workstream X applied to web1's Hermes + mos-claude) is a post-trial phase.
|
|
||||||
|
|
||||||
## Ratified decisions (Jason, 2026-07-09)
|
|
||||||
|
|
||||||
| # | Decision |
|
|
||||||
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| D1 | Jarvis conversation channel is **Matrix-first** — no #jarvis Discord channel is ever created. |
|
|
||||||
| D2 | Mosaic absorbs **all four** Hermes functions before decommission: messaging bridge, Kanban/task board, permission relay, multi-platform reach. |
|
|
||||||
| D3 | Task handoff: **native Mosaic Backlog is the record; Gitea/GitHub/local-kanban attach as bidirectional sync adapters** (upholds ASM-1). |
|
|
||||||
| D4 | Jarvis executes **PA ops directly** (email, calendar, tasks, knowledge, tickets, research); all code/infra/fleet work is delegated to Mos via the backlog. |
|
|
||||||
| D5 | Mosaic Stack is a **product from day one** — multi-user, Authentik tenancy, per-workspace isolation. |
|
|
||||||
| D6 | Multi-platform reach via **Matrix + mautrix bridges** (telegram/signal/whatsapp/slack/discord); agents only ever speak Matrix. |
|
|
||||||
| D7 | webUI builds on the existing `mosaicstack/stack` monorepo (`apps/web`), realizing the already-scoped F6 phase. |
|
|
||||||
| D8 | **PRD first, then Mos runs it** as orchestrated missions. |
|
|
||||||
| D9 | jarvis-brain flat-file data **migrates into the product as tenant #1** (workspace = Jason); brain.py/flat files retire after cutover. |
|
|
||||||
| D10 | PRD form: **extend NORTH_STAR.yaml + per-phase docs in docs/fleet/** (NS-1 compliant). |
|
|
||||||
| D11 | Jarvis runs **Opus**; Fable stays exclusive to Mos per the standing cost directive. Model tier is a persona/profile field. |
|
|
||||||
| D12 | **Trial in the homelab** (the proper mosaic-fleet deployment, built by the homelab agents from `origin/main`), NOT at USC. The USC/web1 environment runs the primitive-era implementation (`mos-claude.service`, Hermes, jarvis-brain boards) and adopts only after the homelab trial validates. Jason relays this PRD to the homelab agent for implementation. |
|
|
||||||
|
|
||||||
## Artifacts in this draft
|
|
||||||
|
|
||||||
| File | Content |
|
|
||||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| `north-star-additions.yaml` | Proposed NORTH_STAR.yaml merge: NS-10…NS-14, workstreams J/K/W/P/Q/X + **M** (memory subsystem) + **L** (logging/telemetry), goal cards with DAG |
|
|
||||||
| `PRD-jarvis-main-agent.md` | Workstream J — the HMI main agent |
|
|
||||||
| `PRD-permission-relay.md` | Workstream P — human-in-the-loop approvals |
|
|
||||||
| `PRD-webui-fleet-control.md` | Workstream W — tmux pop-in + top-down view (realizes F6) |
|
|
||||||
| `PRD-backlog-providers.md` | Workstream Q — provider sync adapters |
|
|
||||||
| `PRD-hermes-decommission.md` | Workstream X — parity checklist, tenant-1 migration, cutover |
|
|
||||||
| `DEBATE-FINDINGS.md` | 2026-07-09 debate pass — all 24 findings dispositioned, open-disagreement judgment calls, process rules |
|
|
||||||
|
|
||||||
Workstreams **M** (M1 enhanced memory subsystem) and **L** (L1 restore MALS · L2 Mosaic-native log ingestion · L3 anonymous agentic telemetry, NS-14) carry no standalone PRD doc: M1's scope is defined by its consumers (X-R7 retrieval eval, J memory rules) and L's by the MALS lineage (`~/src/mals`, infrastructure #135); both live as goal cards in the YAML.
|
|
||||||
|
|
||||||
Workstream K (Matrix connector + mautrix bridges) intentionally has no new PRD doc: it extends the existing `docs/fleet/f4-matrix-connector.md`; its deltas are captured as K-goals in the YAML additions and referenced from the J/P/X PRDs.
|
|
||||||
|
|
||||||
## Relationship to existing upstream work
|
|
||||||
|
|
||||||
- Fleet CLI, persona library, system-type profiles (H1–H4), supervisor/dispatch (B), native backlog (A): **already exist or in flight upstream — not re-specified here.**
|
|
||||||
- `f4-matrix-connector.md`: K1 = its Phase 2 implementation (verified present on `origin/main`; Phase 1 already ships the **tmux-default connector** that serves the P1 CLI channel per J-R14a). K2 (mautrix bridges) is additive infra.
|
|
||||||
- F6 (webUI hooks) in `PRD-fleet-suite.md`: realized by workstream W (verified present on `origin/main`).
|
|
||||||
- `docs/3-architecture/guard-rails-capability-permissions.md`: original design snapshot for workstream P — **not present on `origin/main`** (lives only in the stale `/src/mosaic-stack` clone). Its essential model is therefore folded into `PRD-permission-relay.md`, which is now the **self-contained authoritative spec** for P; the old path is cited as historical origin only.
|
|
||||||
|
|
||||||
## Gate Zero — pre-ratification checklist (debate P0 #3/#4, §3.7)
|
|
||||||
|
|
||||||
Ratification does not proceed on presumption. Before D-level sign-off, run and record:
|
|
||||||
|
|
||||||
1. **Upstream-artifact audit.** Every artifact this PRD load-bears on is verified `present @ <SHA>` on `origin/main` or marked **MISSING → goal card**. Presumed-missing rows already carded by this draft: **M1** (enhanced memory subsystem — nothing on main provides vector DB + memory service today), **J6** (event/wake router), **K3** (Matrix push pipeline + homeserver ops). If an audit finds one of these actually exists, retire the card and pin the SHA; if it finds _another_ gap, card it — no silent presumption in either direction.
|
|
||||||
2. **Hermes traffic audit** (§3.7): per-platform bridge traffic + per-MCP-tool call counts over a trailing window. Platforms with live traffic become the must-have K2 subset gating X3; zero-traffic platforms become post-trial nice-to-haves. Feeds the X-R1 parity checklist and its low-n drill list.
|
|
||||||
3. **Sandbox dispatch-test** of the DAG: load `north-star-additions.yaml` into a sandbox backlog and verify the dispatcher's actual claim order respects every `depends_on` edge (phases are documentation; edges are law).
|
|
||||||
|
|
||||||
## Process rules adopted from the debate pass (normative for this PRD's execution)
|
|
||||||
|
|
||||||
- **Conflict register:** a discovered contradiction between spec documents **blocks the affected requirement** (not the whole mission) until a register entry records the resolution. "Alert, don't auto-resolve" is promoted from data conflicts to spec conflicts. A CI lint greps for register references in amended docs.
|
|
||||||
- **DoD line on every goal card:** each card states its definition-of-done additions — runbook updated, health-floor alert registered, AGENTS.md touched — so operational debt can't silently accrue card-by-card.
|
|
||||||
- **Silent-roster rule:** in any panel/review round, a member's silence records their open findings as **open items, never consensus** (instance: Codex's unanswered principal-resolution and policy-evaluation-time findings were folded as P-R16/P-R17, explicitly not consensus-resolved).
|
|
||||||
- **Immutable spec + typed amendments** (J-R20) applies to these PRD docs themselves post-ratification: changes arrive as amendments with a revision counter, and sign-offs pin the revision.
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
# Proposed additions to docs/fleet/NORTH_STAR.yaml — DRAFT (Jason ratification pending, 2026-07-09)
|
|
||||||
#
|
|
||||||
# Merge these entries into the existing NORTH_STAR.yaml sections, then regenerate
|
|
||||||
# NORTH_STAR.md via renderNorthStarMarkdown (packages/mosaic/src/commands/fleet.ts).
|
|
||||||
# Ids chosen to avoid collision with existing workstreams A–H and goals.
|
|
||||||
|
|
||||||
standing_objectives:
|
|
||||||
- id: NS-10
|
|
||||||
text: >-
|
|
||||||
Every Mosaic workspace runs exactly one always-on HMI main agent (default
|
|
||||||
alias "Jarvis", unit mosaic-agent@main) that owns all human conversation
|
|
||||||
and user-level personal-assistant work (ideas, schedule, email, tasks,
|
|
||||||
knowledge) and delegates engineering/research/ops missions to the
|
|
||||||
orchestrator as Mosaic Backlog cards. It is a Level-0 orchestrator:
|
|
||||||
it accomplishes work through delegation and subagents, never by executing
|
|
||||||
coding/infra tasks itself, and it runs on model capacity separate from the
|
|
||||||
orchestrator so its conversational latency is isolated from fleet load. The
|
|
||||||
main agent never executes fleet work itself and never interrupts the
|
|
||||||
orchestrator for status. Exactly-one-per-workspace is enforced by a
|
|
||||||
workspace lease (J-R16), not by convention.
|
|
||||||
- id: NS-11
|
|
||||||
text: >-
|
|
||||||
Irreversible or externally-visible agent actions pass a human-in-the-loop
|
|
||||||
permission relay (approve/deny from chat or webUI) governed by
|
|
||||||
per-capability guard rails; prepare freely, execute with approval.
|
|
||||||
- id: NS-12
|
|
||||||
text: >-
|
|
||||||
The Mosaic Backlog remains the sole backlog of record; external providers
|
|
||||||
(Gitea, GitHub, local kanban, …) attach as bidirectional sync adapters,
|
|
||||||
never as the record.
|
|
||||||
- id: NS-13
|
|
||||||
text: >-
|
|
||||||
Hermes is fully decommissioned once Mosaic reaches verified parity on
|
|
||||||
transport (Matrix connector), task board (native backlog + webUI),
|
|
||||||
permission relay, and multi-platform reach (mautrix bridges).
|
|
||||||
- id: NS-14
|
|
||||||
text: >-
|
|
||||||
Mosaic Stack accepts inbound structured error/log reporting from every
|
|
||||||
agent and install (MALS-lineage logging service); agentic-efficiency
|
|
||||||
telemetry is optional, opt-in, and anonymous by construction — no IP or
|
|
||||||
PII is captured or derivable from the ingestion path.
|
|
||||||
|
|
||||||
success_criteria:
|
|
||||||
- id: AC-NS-8
|
|
||||||
text: >-
|
|
||||||
A user converses with the main agent in its channel while the
|
|
||||||
orchestrator is under full load; because the main agent runs on separate
|
|
||||||
model capacity (distinct credential/quota pools pinned in the J1
|
|
||||||
profile), its response latency is unaffected and the orchestrator
|
|
||||||
receives zero conversational traffic. Measured, not vibes: scripted
|
|
||||||
suite, >=30 interleaved turns, TTFT p95 <= 1.2x idle baseline with
|
|
||||||
bootstrap CI.
|
|
||||||
- id: AC-NS-9
|
|
||||||
text: >-
|
|
||||||
A mission agreed in the main-agent conversation appears as a backlog card
|
|
||||||
set with acceptance criteria, is drained by the orchestrator without
|
|
||||||
chat-level handoff, and its completion is reported back to the user by the
|
|
||||||
main agent from board/heartbeat state alone.
|
|
||||||
- id: AC-NS-10
|
|
||||||
text: >-
|
|
||||||
An action listed as requires_approval executes only after an explicit
|
|
||||||
human approve from Matrix or webUI; deny and timeout paths leave the
|
|
||||||
system unchanged and audited.
|
|
||||||
- id: AC-NS-11
|
|
||||||
text: >-
|
|
||||||
With Hermes stopped, no fleet or main-agent capability regresses
|
|
||||||
(transport, board, approvals, multi-platform reach all served by Mosaic).
|
|
||||||
|
|
||||||
workstreams:
|
|
||||||
- id: J
|
|
||||||
title: HMI main agent ("Jarvis") — persona, PA toolchain, delegation contract
|
|
||||||
- id: K
|
|
||||||
title: Connectors & multi-platform reach — F4 Matrix implementation + mautrix bridges
|
|
||||||
- id: W
|
|
||||||
title: webUI fleet control — tmux pop-in, top-down view (realizes F6)
|
|
||||||
- id: P
|
|
||||||
title: Permission relay — capability guard rails + human approval queue
|
|
||||||
- id: Q
|
|
||||||
title: Backlog provider sync adapters — Gitea/GitHub/local kanban
|
|
||||||
- id: X
|
|
||||||
title: Hermes decommission & tenant-1 migration
|
|
||||||
- id: M
|
|
||||||
title: Enhanced memory subsystem — vector DB + memory service + provenance/tombstones (Gate Zero MISSING artifact)
|
|
||||||
- id: L
|
|
||||||
title: Logging & telemetry — MALS-lineage inbound error reporting + optional anonymous efficiency telemetry
|
|
||||||
|
|
||||||
goals:
|
|
||||||
# J — HMI main agent
|
|
||||||
- id: J1
|
|
||||||
title: Main-agent persona + profile — instantiate personal-assistant system type as mosaic-agent@main (alias Jarvis), model tier a profile field (Opus default)
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [H2]
|
|
||||||
# J2 split (debate P0 #2): phase-1 Jarvis must not hold an external-write
|
|
||||||
# credential path before the permission relay (P2) exists — NS-11 by DAG.
|
|
||||||
- id: J2a
|
|
||||||
title: PA toolchain (workspace-internal) — tasks, events, knowledge, ideas executed directly against the product API in the user's workspace; no external credentials provisioned
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [J1]
|
|
||||||
- id: J2b
|
|
||||||
title: PA toolchain (external integrations) — email, external calendars, helpdesk via workspace-scoped integrations; credentials gateway-held; requires_approval routes through the relay
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [J2a, P2]
|
|
||||||
- id: J3
|
|
||||||
title: Delegation contract — main agent authors mission cards (goal, acceptance criteria, budget advisory) onto the backlog; orchestrator drains; no chat-level handoff
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [J1, A3a]
|
|
||||||
- id: J4
|
|
||||||
title: Passive fleet observability — main agent answers status from heartbeats, fleet ps JSON, and board state; zero orchestrator interrupts
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [J1, B1]
|
|
||||||
- id: J5
|
|
||||||
title: Main-agent Matrix room via OrchestratorConnector(matrix)
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [J1, K1]
|
|
||||||
- id: J6
|
|
||||||
title: Shared event/wake router — level-triggered reconciler over durable state (heartbeats, cards, approvals) with hysteretic per-condition suppression; templated provenance-tagged wake turns; per-agent polling forbidden (Gate Zero MISSING artifact; J-R13 depends on it)
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [J1]
|
|
||||||
|
|
||||||
# K — connectors & reach (extends f4-matrix-connector.md)
|
|
||||||
- id: K1
|
|
||||||
title: Matrix connector implementation — CS-API client factory per f4 Phase 2, self-hosted homeserver
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: []
|
|
||||||
# K2 scope (debate §3.7): a pre-ratification Hermes traffic audit narrows the
|
|
||||||
# must-have bridge subset to platforms with live traffic; only that subset
|
|
||||||
# gates X3 — remaining bridges are genuinely optional post-X3.
|
|
||||||
- id: K2
|
|
||||||
title: mautrix bridge deployment (telegram/signal/whatsapp/slack/discord) as GitOps-managed infra; agents speak only Matrix; must-have subset = platforms carrying live Hermes traffic per Gate Zero audit
|
|
||||||
phase: 3
|
|
||||||
priority: should-have
|
|
||||||
depends_on: [K1]
|
|
||||||
- id: K3
|
|
||||||
title: Push pipeline (Sygnal or equivalent) + homeserver ops — monitored delivery checks, health-floor registration, rehearsed backup/restore (Gate Zero MISSING artifact; P2 phone-approval AC depends on it)
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [K1]
|
|
||||||
|
|
||||||
# W — webUI fleet control (realizes F6)
|
|
||||||
- id: W1
|
|
||||||
title: Gateway pty/tmux attach service — read-only watch and interactive butt-in verbs, workspace-scoped authz, audit log
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: []
|
|
||||||
- id: W2
|
|
||||||
title: xterm.js session view in apps/web wired to W1 (watch + butt-in)
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [W1]
|
|
||||||
- id: W3
|
|
||||||
title: Top-down fleet dashboard — roster, heartbeats, cards in flight, advisory spend, PAUSE control
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [B1]
|
|
||||||
|
|
||||||
# P — permission relay
|
|
||||||
- id: P1
|
|
||||||
title: Capability guard-rails engine — resource:action grants, permission levels, requires_approval list per integration
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: []
|
|
||||||
- id: P2
|
|
||||||
title: Approval queue + approve/deny from the Matrix room (timeout = deny; full audit)
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [P1, K1]
|
|
||||||
- id: P3
|
|
||||||
title: Approval surface in webUI (pending queue, one-click approve/deny)
|
|
||||||
phase: 3
|
|
||||||
priority: should-have
|
|
||||||
depends_on: [P1, W3]
|
|
||||||
|
|
||||||
# Q — backlog provider sync adapters
|
|
||||||
- id: Q1
|
|
||||||
title: Provider adapter interface + Gitea adapter (bidirectional card↔issue sync; native backlog stays record)
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [A2]
|
|
||||||
- id: Q2
|
|
||||||
title: GitHub adapter
|
|
||||||
phase: 3
|
|
||||||
priority: should-have
|
|
||||||
depends_on: [Q1]
|
|
||||||
- id: Q3
|
|
||||||
title: Local kanban surface — webUI board view over the native backlog (no external provider required)
|
|
||||||
phase: 2
|
|
||||||
priority: should-have
|
|
||||||
depends_on: [A2, W3]
|
|
||||||
|
|
||||||
# X — Hermes decommission & tenant-1 migration
|
|
||||||
- id: X1
|
|
||||||
title: Hermes parity checklist + cutover plan (transport, board, approvals, reach) with rollback
|
|
||||||
phase: 3
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [K1, P2, Q1]
|
|
||||||
# jarvis-brain is the P0 (prototype) Mosaic Stack; migration targets the proper
|
|
||||||
# stack storage layer. Storage authority (debate P0 #1): Postgres is
|
|
||||||
# authoritative for ALL product entities; the flat-file backend is a derived,
|
|
||||||
# regenerated, READ-ONLY projection — never a second writable store.
|
|
||||||
- id: X2
|
|
||||||
title: 'Tenant-1 migration — P0 (jarvis-brain) into the proper Mosaic Stack: (a) PA data (projects/tasks/events/knowledge) into the Jason workspace, (b) agent memory/runbooks into the enhanced memory subsystem M1 (kept live, not frozen); jarvis-brain retires read-only only after both are verified'
|
|
||||||
phase: 3
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [J2a, P2, M1]
|
|
||||||
- id: X3
|
|
||||||
title: Hermes decommission — stop and remove Hermes services after AC-NS-11 verified; pre-stop evidence snapshot (logs/config/callback inventory + checksum) is a machine gate
|
|
||||||
phase: 4
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [X1, X2, K2]
|
|
||||||
|
|
||||||
# M — enhanced memory subsystem (Gate Zero: cited by X2(b) but not built by
|
|
||||||
# any prior workstream — this card closes that gap)
|
|
||||||
- id: M1
|
|
||||||
title: Enhanced memory subsystem — vector DB + memory service with mandatory provenance (human/agent/external), source-grounded retrieval preference, tombstones + re-embedding on source update, normative/parametric corpus split
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: []
|
|
||||||
|
|
||||||
# L — logging & telemetry (Jason 2026-07-09; NS-14). MALS (~/src/mals) is the
|
|
||||||
# P0 of this capability; restoring it is infra work tracked as
|
|
||||||
# infrastructure#135 (k3s migration landed without an Ingress).
|
|
||||||
- id: L1
|
|
||||||
title: MALS restored & exposed — k3s Ingress for mals.mosaicstack.dev, health verified, authenticated write smoke-tested (infrastructure#135); trial day-1 metric emission targets MALS until W3 panels exist
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: []
|
|
||||||
- id: L2
|
|
||||||
title: Mosaic-native log ingestion — gateway/API endpoint for structured inbound error reporting from agents and installs (MALS-compatible schema; levels, categories, trace ids), workspace-scoped keys
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [L1]
|
|
||||||
- id: L3
|
|
||||||
title: Anonymous agentic-efficiency telemetry — opt-in, aggregate-only, no IP/PII captured or derivable at ingestion (NS-14); feeds W3 trend panels
|
|
||||||
phase: 3
|
|
||||||
priority: should-have
|
|
||||||
depends_on: [L2, W3]
|
|
||||||
|
|
||||||
assumptions:
|
|
||||||
- id: ASM-5
|
|
||||||
vetoable: true
|
|
||||||
text: >-
|
|
||||||
The main agent initially runs on the homelab fleet host alongside the
|
|
||||||
orchestrator under mosaic-agent@main.service; host placement is a config
|
|
||||||
field, not a code assumption. Host co-location does NOT imply shared
|
|
||||||
inference: Jarvis (Opus) and Mos (Fable) hold separate model capacity/quota
|
|
||||||
so orchestrator load cannot degrade conversational latency (AC-NS-8).
|
|
||||||
- id: ASM-6
|
|
||||||
vetoable: true
|
|
||||||
text: >-
|
|
||||||
Product multi-tenancy at MVP means self-hosted installs with multiple
|
|
||||||
workspaces per install (Authentik OIDC); per-tenant isolated FLEETS
|
|
||||||
(agents per workspace) are post-MVP.
|
|
||||||
- id: ASM-7
|
|
||||||
vetoable: true
|
|
||||||
text: >-
|
|
||||||
mautrix bridges are deployed as infrastructure (GitOps), not as Mosaic
|
|
||||||
application code; Mosaic's only conversational protocol is Matrix.
|
|
||||||
- id: ASM-8
|
|
||||||
vetoable: true
|
|
||||||
text: >-
|
|
||||||
During migration (before X3), Hermes remains running untouched; no
|
|
||||||
Hermes-dependent capability is removed until its Mosaic replacement is
|
|
||||||
verified in production.
|
|
||||||
- id: ASM-9
|
|
||||||
vetoable: true
|
|
||||||
text: >-
|
|
||||||
The trial environment is the homelab fleet deployment (D12). Environments
|
|
||||||
running the primitive-era implementation (USC/web1: mos-claude.service,
|
|
||||||
Hermes, jarvis-brain boards) are untouched during the trial; workstream X
|
|
||||||
executes there as a post-trial adoption phase, environment by
|
|
||||||
environment.
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# Scratchpad — FED-M3-04 Scope Service
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement `apps/gateway/src/federation/server/scope.service.ts` for the M3 inbound federation scope-enforcement pipeline.
|
|
||||||
|
|
||||||
## Scope / Constraints
|
|
||||||
|
|
||||||
- Task: FED-M3-04, issue #462.
|
|
||||||
- Branch: `feat/federation-m3-scope-service` from `origin/main` @ 0.0.48.
|
|
||||||
- Pure service: no direct DB access; native RBAC/data access is injected per evaluation call.
|
|
||||||
- Reuse `parseFederationScope` from M2-03.
|
|
||||||
- Workers do not edit `docs/federation/TASKS.md` per repo AGENTS.md.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
1. Resource allowlist and `excluded_resources` enforced.
|
|
||||||
2. Native RBAC evaluated as `subjectUserId` through an injected evaluator.
|
|
||||||
3. Scope filter intersection supports `include_teams` and `include_personal` without widening native RBAC.
|
|
||||||
4. `max_rows_per_query` caps requested limits.
|
|
||||||
5. Service returns `{ allowed: true, filter }` or a structured deny reason usable by M4 audit.
|
|
||||||
6. Unit tests cover every deny path.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Inspect existing federation scope/schema/auth guard contracts.
|
|
||||||
2. Add pure `FederationScopeService` plus typed result/filter/deny interfaces.
|
|
||||||
3. Add focused unit tests for happy paths, filter intersection, row cap, and deny paths.
|
|
||||||
4. Export/register service for future verb controllers.
|
|
||||||
5. Run situational tests, baseline gates, code review, then PR.
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
- Provided model tier: sonnet.
|
|
||||||
- Estimate from task row: 10K tokens.
|
|
||||||
- Working cap assumption: keep implementation focused to FED-M3-04 surfaces only.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- Intake complete; dirty base worktree avoided by creating isolated worktree at `/home/jarvis/src/mosaic-mono-v1-fed-m3-04`.
|
|
||||||
- Project PRD and federation task spec reviewed.
|
|
||||||
- Added `FederationScopeService` with structured allow/deny result types and injected native RBAC evaluator contract.
|
|
||||||
- Added unit coverage for happy path, row cap, filter intersection, and every deny path.
|
|
||||||
- Exported/registered the service for upcoming M3 verb controllers.
|
|
||||||
|
|
||||||
## Verification Evidence
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/__tests__/scope.service.spec.ts` — pass (10 tests before review update; 11 tests after adding include_personal no-leak coverage).
|
|
||||||
- `pnpm build` — pass (23 successful tasks).
|
|
||||||
- `pnpm typecheck` — pass (41 successful tasks; re-run after review update).
|
|
||||||
- `pnpm lint` — pass (23 successful tasks; re-run after review update).
|
|
||||||
- `pnpm format:check` — pass (re-run after review update).
|
|
||||||
- `pnpm test` — pass after starting local `postgres`/`valkey` and running `pnpm --filter @mosaicstack/db db:push` for the DB-backed cross-user isolation suite (41 successful tasks; gateway 477 passed / 11 skipped).
|
|
||||||
- Code review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings.
|
|
||||||
- Security review: `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — risk none, 0 findings.
|
|
||||||
|
|
||||||
## Risks / Blockers
|
|
||||||
|
|
||||||
- Issue #462 is already closed in provider output; likely milestone tracking mismatch. Will still reference #462 in PR body unless orchestrator redirects.
|
|
||||||
- Local full-test setup required `docker compose up -d postgres valkey` + `db:push`; containers were stopped with `docker compose down` after verification.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Issue #561 — Bare python on agent hosts
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Make the durable bootstrap/provisioning guidance ensure agent hosts provide a bare `python` command that resolves to Python 3.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Add Debian/Ubuntu `python-is-python3` to agent-host prerequisites in bootstrap docs.
|
|
||||||
- Check for actual OS package provisioning scripts and update only if an existing agent-host package install path exists.
|
|
||||||
- Do not touch live host state.
|
|
||||||
- Do not update `docs/TASKS.md`; repo guidance says workers read it but never modify it.
|
|
||||||
|
|
||||||
## Recon
|
|
||||||
|
|
||||||
- Issue #561 confirms repeated `python: command not found` failures from fleet agents that emit `python foo.py`.
|
|
||||||
- `guides/BOOTSTRAP.md` and `packages/mosaic/framework/guides/BOOTSTRAP.md` are the source and packaged framework copies of the bootstrap guide.
|
|
||||||
- Targeted repo sweep found no agent-host Debian package provisioning script. Existing `apt-get install` hits are CI/test helper paths or unrelated deployment docs.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Add a host prerequisite section to both bootstrap guide copies.
|
|
||||||
2. Include `python-is-python3` in the Debian/Ubuntu package list with an issue comment.
|
|
||||||
3. Note the non-Debian equivalent as a `/usr/bin/python -> python3` symlink.
|
|
||||||
4. Validate markdown/diff, run shell syntax checks where applicable, run required review, commit, queue guard, and push.
|
|
||||||
|
|
||||||
## Validation Log
|
|
||||||
|
|
||||||
- `rg` recon: no existing agent-host Debian package provisioning script; only CI/test helper `apt-get install` paths and unrelated deployment docs.
|
|
||||||
- `git diff --check`: passed.
|
|
||||||
- `bash -n packages/mosaic/framework/install.sh tools/install.sh packages/mosaic/framework/tools/bootstrap/init-project.sh packages/mosaic/framework/tools/_scripts/mosaic-bootstrap-repo`: passed. No touched shell scripts.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`: approved, 0 findings.
|
|
||||||
- `pnpm format:check`: initially blocked because `node_modules` was absent and `prettier` was unavailable; `pnpm install --frozen-lockfile` initially hit an invalid `/root` pnpm store path. Reran install with `--store-dir /home/hermes/agent-work/.pnpm-store`, then `pnpm format:check` passed.
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# FED-M3-05 — Federation List Verb Scratchpad
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement `POST /api/federation/v1/list/:resource`.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Wire `FederationAuthGuard` → `FederationScopeService` → read-only list query layer.
|
|
||||||
- Apply `max_rows_per_query` row cap and return pagination metadata when truncated.
|
|
||||||
- Tag returned rows with `_source: "local"`.
|
|
||||||
- Keep audit writes deferred to M4.
|
|
||||||
- No request/response body persistence.
|
|
||||||
|
|
||||||
## Base / branch
|
|
||||||
|
|
||||||
- Branch: `feat/federation-m3-verb-list`
|
|
||||||
- Base: `main` after M3-04 scope service merged via PR #672 (`c739256a`).
|
|
||||||
|
|
||||||
## Implementation notes
|
|
||||||
|
|
||||||
- Added `ListController` under `apps/gateway/src/federation/server/verbs/`.
|
|
||||||
- Added `FederationListQueryService` as the read-only query layer and native RBAC evaluator.
|
|
||||||
- Query resources supported in M3 list path:
|
|
||||||
- `tasks`: project/mission scoped tasks visible through personal/team project access.
|
|
||||||
- `notes`: non-empty `mission_tasks.notes` rows visible through personal/team mission access.
|
|
||||||
- `memory`: user-owned `insights` and `preferences` rows.
|
|
||||||
- `credentials` / `api_keys`: denied by native RBAC in M3 even if present in scope; sensitive-resource implementation is not part of FED-M3-05.
|
|
||||||
- Cursor pagination uses an opaque base64url keyset cursor over `(createdAt, id)`; DB reads fetch at most `limit + 1` rows per resource query.
|
|
||||||
- Reviewer isolation fix: `mission_tasks.notes` rows are always constrained by `missionTasks.userId = subjectUserId` and accessible mission IDs; team scope narrows missions but never widens to other users' mission task notes.
|
|
||||||
- Follow-up review fix: memory listing now uses deterministic table-block pagination (`insights` first, then `preferences`) with cursor source metadata, so one table's cursor is never applied to the other.
|
|
||||||
- Follow-up hardening: missing auth-guard context returns a structured federation `unauthorized` envelope; unsupported resources and non-encodable truncated cursors throw instead of silently crashing/truncating.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test -- list.controller.spec.ts list-query.service.spec.ts` — PASS (16 tests, including PGlite regression coverage for team-scoped notes isolation, unauthorized mission notes exclusion, `includePersonal: false`, deterministic memory pagination, missing context envelope, unsupported resource, and cursor encode failure).
|
|
||||||
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
|
|
||||||
- `pnpm format:check` — PASS.
|
|
||||||
- `pnpm typecheck` — PASS (41/41 turbo tasks).
|
|
||||||
- `pnpm lint` — PASS (23/23 turbo tasks).
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup cannot connect to local PostgreSQL on `localhost:5433`. New list tests pass; failure is outside FED-M3-05.
|
|
||||||
|
|
||||||
## Review evidence
|
|
||||||
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS after follow-up remediation; approve, no findings.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS after follow-up remediation; risk level none, no findings.
|
|
||||||
- Security-review note: read-path audit logging remains intentionally deferred to M4 per orchestrator clarification and FED-M3-05 scope.
|
|
||||||
|
|
||||||
## Risks / follow-up
|
|
||||||
|
|
||||||
- Read-path audit logging remains intentionally deferred to M4.
|
|
||||||
@@ -15,22 +15,6 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
|
|||||||
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
|
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
|
||||||
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
|
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
|
||||||
|
|
||||||
## Agent Host Prerequisites
|
|
||||||
|
|
||||||
Agent hosts must provide the Python runtime shape that runtime agents and
|
|
||||||
Mosaic automation assume is present.
|
|
||||||
|
|
||||||
For Debian/Ubuntu hosts:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt-get update
|
|
||||||
# #561: bare python invocations from agents must resolve.
|
|
||||||
sudo apt-get install -y python3 python-is-python3
|
|
||||||
```
|
|
||||||
|
|
||||||
For non-Debian hosts, install the equivalent Python 3 runtime and ensure
|
|
||||||
`/usr/bin/python` resolves to `python3` (for example, via a managed symlink).
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -15,22 +15,6 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
|
|||||||
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
|
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
|
||||||
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
|
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
|
||||||
|
|
||||||
## Agent Host Prerequisites
|
|
||||||
|
|
||||||
Agent hosts must provide the Python runtime shape that runtime agents and
|
|
||||||
Mosaic automation assume is present.
|
|
||||||
|
|
||||||
For Debian/Ubuntu hosts:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt-get update
|
|
||||||
# #561: bare python invocations from agents must resolve.
|
|
||||||
sudo apt-get install -y python3 python-is-python3
|
|
||||||
```
|
|
||||||
|
|
||||||
For non-Debian hosts, install the equivalent Python 3 runtime and ensure
|
|
||||||
`/usr/bin/python` resolves to `python3` (for example, via a managed symlink).
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -87,10 +87,6 @@ message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards.
|
|||||||
|
|
||||||
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
|
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
|
||||||
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
|
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
|
||||||
- `auto-submit-drafts.sh` — watchdog that flushes stable unsubmitted prompt
|
|
||||||
drafts on a coordinator pane (default target `mos-claude`); run it as a
|
|
||||||
long-lived process alongside the coordinator session.
|
|
||||||
- `agent-send.test.sh` — regression + grammar lock for `agent-send.sh`.
|
|
||||||
- `test-send-message-socket.sh` — smoke test for named-socket isolation.
|
- `test-send-message-socket.sh` — smoke test for named-socket isolation.
|
||||||
|
|
||||||
## Distribution
|
## Distribution
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# auto-submit-drafts.sh — watchdog for Claude Code panes that receive channel
|
|
||||||
# messages but leave them as unsubmitted prompt drafts. Intended for Mos only.
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
TARGET="${1:-mos-claude}"
|
|
||||||
INTERVAL="${INTERVAL:-2}"
|
|
||||||
STABLE_SECONDS="${STABLE_SECONDS:-4}"
|
|
||||||
LOG_PREFIX="[auto-submit-drafts:$TARGET]"
|
|
||||||
|
|
||||||
last_prompt=""
|
|
||||||
first_seen=0
|
|
||||||
|
|
||||||
prompt_text() {
|
|
||||||
tmux capture-pane -t "$TARGET" -p 2>/dev/null | python3 -c '
|
|
||||||
import sys, re
|
|
||||||
lines = sys.stdin.read().splitlines()
|
|
||||||
idx = None
|
|
||||||
for i in range(len(lines)-1, -1, -1):
|
|
||||||
if "❯" in lines[i]:
|
|
||||||
idx = i
|
|
||||||
break
|
|
||||||
if idx is None:
|
|
||||||
raise SystemExit
|
|
||||||
parts = []
|
|
||||||
after = lines[idx].split("❯", 1)[1]
|
|
||||||
parts.append(after)
|
|
||||||
for line in lines[idx+1:]:
|
|
||||||
# Stop at Claude Code separator/border lines.
|
|
||||||
if "─" in line or "╰" in line or "╭" in line:
|
|
||||||
break
|
|
||||||
s = line.replace("\u00a0", " ")
|
|
||||||
s = re.sub(r"[\x00-\x1f\x7f]", "", s).strip()
|
|
||||||
if s:
|
|
||||||
parts.append(s)
|
|
||||||
text = " ".join(parts).replace("\u00a0", " ")
|
|
||||||
text = re.sub(r"[\x00-\x1f\x7f]", "", text).strip()
|
|
||||||
print(text)
|
|
||||||
'
|
|
||||||
}
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
if ! tmux has-session -t "$TARGET" 2>/dev/null; then
|
|
||||||
echo "$LOG_PREFIX target missing; waiting" >&2
|
|
||||||
sleep "$INTERVAL"
|
|
||||||
last_prompt=""
|
|
||||||
first_seen=0
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
current="$(prompt_text || true)"
|
|
||||||
now="$(date +%s)"
|
|
||||||
|
|
||||||
if [[ -z "$current" ]]; then
|
|
||||||
last_prompt=""
|
|
||||||
first_seen=0
|
|
||||||
sleep "$INTERVAL"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$current" != "$last_prompt" ]]; then
|
|
||||||
last_prompt="$current"
|
|
||||||
first_seen="$now"
|
|
||||||
sleep "$INTERVAL"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
age=$(( now - first_seen ))
|
|
||||||
if (( age >= STABLE_SECONDS )); then
|
|
||||||
echo "$LOG_PREFIX submitting stable draft after ${age}s: ${current:0:120}" >&2
|
|
||||||
tmux send-keys -t "$TARGET" C-j
|
|
||||||
sleep 0.8
|
|
||||||
tmux send-keys -t "$TARGET" C-m
|
|
||||||
sleep 2
|
|
||||||
last_prompt=""
|
|
||||||
first_seen=0
|
|
||||||
else
|
|
||||||
sleep "$INTERVAL"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
@@ -77,20 +77,10 @@ snippet=$(printf '%s' "$MSG" | tr '\n' ' ' | tr -s ' ' | sed 's/[^[:print:]]//g'
|
|||||||
|
|
||||||
# 1) Paste the body as a bracketed paste so multi-line content does not submit
|
# 1) Paste the body as a bracketed paste so multi-line content does not submit
|
||||||
# line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`.
|
# line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`.
|
||||||
# Buffer name MUST be unique per invocation: concurrent senders on the shared
|
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b __mosaic_send -
|
||||||
# tmux server race a fixed name (load overwrites load, -d deletes underneath),
|
|
||||||
# cross-delivering or dropping messages — bit the fleet on the 2026-07-09
|
|
||||||
# simultaneous restart (briefs swapped between sessions).
|
|
||||||
BUF="__mosaic_send_$$_$(date +%s%N)"
|
|
||||||
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
|
|
||||||
# -p = bracketed paste when the client supports it; fall back if not.
|
# -p = bracketed paste when the client supports it; fall back if not.
|
||||||
"${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \
|
"${tmux_cmd[@]}" paste-buffer -d -p -b __mosaic_send -t "$EFFECTIVE_TARGET" 2>/dev/null \
|
||||||
|| "${tmux_cmd[@]}" paste-buffer -d -b "$BUF" -t "$EFFECTIVE_TARGET" \
|
|| "${tmux_cmd[@]}" paste-buffer -d -b __mosaic_send -t "$EFFECTIVE_TARGET"
|
||||||
|| "${tmux_cmd[@]}" delete-buffer -b "$BUF" 2>/dev/null
|
|
||||||
# ^ -d deletes the buffer only on a SUCCESSFUL paste; if both attempts fail
|
|
||||||
# (e.g. the target vanished since the liveness check), delete explicitly —
|
|
||||||
# named buffers are exempt from tmux's buffer-limit eviction, so orphans
|
|
||||||
# would otherwise accumulate forever.
|
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
|
|
||||||
# 2) Submit, then verify; flush with another Enter if it is still a draft.
|
# 2) Submit, then verify; flush with another Enter if it is still a draft.
|
||||||
|
|||||||
@@ -47,32 +47,4 @@ if capture_default | grep -qF "agent socket hello"; then
|
|||||||
fail "agent-send.sh leaked named-socket message to default tmux server"
|
fail "agent-send.sh leaked named-socket message to default tmux server"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Concurrency: parallel senders on one server must not cross-deliver or drop.
|
|
||||||
# Locks the unique-per-invocation paste buffer (a fixed buffer name raced:
|
|
||||||
# load overwrote load, -d deleted underneath — messages swapped between panes).
|
|
||||||
CONC_N=5
|
|
||||||
for i in $(seq 1 "$CONC_N"); do
|
|
||||||
tmux -L "$SOCKET" new-session -d -s "conc-$i" -c "$TMPDIR" 'bash --noprofile --norc -i'
|
|
||||||
done
|
|
||||||
pids=()
|
|
||||||
for i in $(seq 1 "$CONC_N"); do
|
|
||||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=conc-$i" -m "CONCPAYLOAD-${i}-END" >/dev/null &
|
|
||||||
pids+=($!)
|
|
||||||
done
|
|
||||||
for pid in "${pids[@]}"; do
|
|
||||||
wait "$pid" || fail "concurrent send-message.sh invocation exited non-zero"
|
|
||||||
done
|
|
||||||
sleep 0.2
|
|
||||||
for i in $(seq 1 "$CONC_N"); do
|
|
||||||
pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p)
|
|
||||||
printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${i}-END" \
|
|
||||||
|| fail "concurrent send dropped payload for pane conc-$i"
|
|
||||||
for j in $(seq 1 "$CONC_N"); do
|
|
||||||
[ "$j" = "$i" ] && continue
|
|
||||||
if printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${j}-END"; then
|
|
||||||
fail "concurrent send cross-delivered payload $j to pane conc-$i"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "ok - named tmux socket send tools"
|
echo "ok - named tmux socket send tools"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { dirname, join, resolve } from 'node:path';
|
|||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
acquireRestartLock,
|
|
||||||
addAgentToRoster,
|
addAgentToRoster,
|
||||||
buildAgentSendCommand,
|
buildAgentSendCommand,
|
||||||
buildAgentWatchAttachCommand,
|
buildAgentWatchAttachCommand,
|
||||||
@@ -46,8 +45,6 @@ import {
|
|||||||
removeAgentFromRoster,
|
removeAgentFromRoster,
|
||||||
resolveFleetPaths,
|
resolveFleetPaths,
|
||||||
resolvePresetFilename,
|
resolvePresetFilename,
|
||||||
restartLockPath,
|
|
||||||
RESTART_LOCK_STALE_MS,
|
|
||||||
RUNTIME_ACCEPTABLE_COMMANDS,
|
RUNTIME_ACCEPTABLE_COMMANDS,
|
||||||
serializeRosterToYaml,
|
serializeRosterToYaml,
|
||||||
VERIFY_DEFAULT_TIMEOUT_MS,
|
VERIFY_DEFAULT_TIMEOUT_MS,
|
||||||
@@ -681,364 +678,6 @@ describe('fleet command construction', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('waits for an in-flight restart to clear before relaunching (re-entry guard)', async () => {
|
|
||||||
const home = await tempDir();
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
rosterPath,
|
|
||||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
|
||||||
'\n',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Simulate another `mosaic fleet restart` process mid-teardown: a fresh lock
|
|
||||||
// (recent timestamp, so it is NOT treated as stale) already held.
|
|
||||||
const lockPath = restartLockPath(home);
|
|
||||||
await mkdir(dirname(lockPath), { recursive: true });
|
|
||||||
await writeFile(lockPath, `4242\n${Date.now()}\n`);
|
|
||||||
|
|
||||||
const events: string[] = [];
|
|
||||||
const runner: CommandRunner = async (command, args) => {
|
|
||||||
events.push(`run:${args[args.length - 1]}`);
|
|
||||||
return { stdout: '', stderr: '', exitCode: 0 };
|
|
||||||
};
|
|
||||||
// The injected sleep stands in for time passing while we wait; the in-flight
|
|
||||||
// restart "finishes" (releases its lock) after the first poll.
|
|
||||||
let sleeps = 0;
|
|
||||||
const sleepFn: SleepFn = async () => {
|
|
||||||
sleeps += 1;
|
|
||||||
events.push(`sleep:${sleeps}`);
|
|
||||||
await rm(lockPath, { force: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
|
|
||||||
|
|
||||||
// It must have waited at least once before issuing any systemctl restart.
|
|
||||||
expect(sleeps).toBeGreaterThan(0);
|
|
||||||
const firstSleep = events.findIndex((e) => e.startsWith('sleep:'));
|
|
||||||
const firstRun = events.findIndex((e) => e.startsWith('run:'));
|
|
||||||
expect(firstSleep).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(firstRun).toBeGreaterThan(firstSleep);
|
|
||||||
|
|
||||||
// And it still performs the full restart once the lock clears.
|
|
||||||
expect(events).toContain('run:mosaic-tmux-holder.service');
|
|
||||||
expect(events).toContain('run:mosaic-agent@coder0.service');
|
|
||||||
|
|
||||||
// The lock is released after the restart completes.
|
|
||||||
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
||||||
} finally {
|
|
||||||
await rm(home, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('breaks a stale restart lock and proceeds without waiting', async () => {
|
|
||||||
const home = await tempDir();
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
rosterPath,
|
|
||||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
|
||||||
'\n',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// A lock left behind by a crashed owner: timestamp older than the stale window.
|
|
||||||
const lockPath = restartLockPath(home);
|
|
||||||
await mkdir(dirname(lockPath), { recursive: true });
|
|
||||||
await writeFile(lockPath, `4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\n`);
|
|
||||||
|
|
||||||
const calls: string[][] = [];
|
|
||||||
const runner: CommandRunner = async (command, args) => {
|
|
||||||
calls.push([command, ...args]);
|
|
||||||
return { stdout: '', stderr: '', exitCode: 0 };
|
|
||||||
};
|
|
||||||
const sleepFn = vi.fn<SleepFn>(async () => {});
|
|
||||||
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
|
|
||||||
|
|
||||||
// Stale lock is broken immediately — no waiting.
|
|
||||||
expect(sleepFn).not.toHaveBeenCalled();
|
|
||||||
expect(calls).toEqual([
|
|
||||||
['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
|
|
||||||
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
|
|
||||||
]);
|
|
||||||
// The stale lock is gone once the restart completes.
|
|
||||||
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
||||||
} finally {
|
|
||||||
await rm(home, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('releases the restart lock so a subsequent restart is not blocked', async () => {
|
|
||||||
const home = await tempDir();
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
rosterPath,
|
|
||||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
|
||||||
'\n',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const calls: string[][] = [];
|
|
||||||
const runner: CommandRunner = async (command, args) => {
|
|
||||||
calls.push([command, ...args]);
|
|
||||||
return { stdout: '', stderr: '', exitCode: 0 };
|
|
||||||
};
|
|
||||||
const sleepFn = vi.fn<SleepFn>(async () => {});
|
|
||||||
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
|
|
||||||
|
|
||||||
// Two sequential restarts both run fully and neither has to wait.
|
|
||||||
expect(sleepFn).not.toHaveBeenCalled();
|
|
||||||
expect(calls).toEqual([
|
|
||||||
['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
|
|
||||||
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
|
|
||||||
['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
|
|
||||||
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
await rm(home, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('guards the single-agent restart path behind the in-flight restart lock', async () => {
|
|
||||||
const home = await tempDir();
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
rosterPath,
|
|
||||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
|
||||||
'\n',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// A full restart is mid-flight (lock held); a single-agent restart re-enters.
|
|
||||||
const lockPath = restartLockPath(home);
|
|
||||||
await mkdir(dirname(lockPath), { recursive: true });
|
|
||||||
await writeFile(lockPath, `4242\n${Date.now()}\n`);
|
|
||||||
|
|
||||||
const events: string[] = [];
|
|
||||||
const runner: CommandRunner = async (command, args) => {
|
|
||||||
events.push(`run:${args[args.length - 1]}`);
|
|
||||||
return { stdout: '', stderr: '', exitCode: 0 };
|
|
||||||
};
|
|
||||||
let sleeps = 0;
|
|
||||||
const sleepFn: SleepFn = async () => {
|
|
||||||
sleeps += 1;
|
|
||||||
events.push(`sleep:${sleeps}`);
|
|
||||||
await rm(lockPath, { force: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart', 'coder0']);
|
|
||||||
|
|
||||||
// The single-agent restart waits for the in-flight restart before acting.
|
|
||||||
expect(sleeps).toBeGreaterThan(0);
|
|
||||||
const firstSleep = events.findIndex((e) => e.startsWith('sleep:'));
|
|
||||||
const firstRun = events.findIndex((e) => e.startsWith('run:'));
|
|
||||||
expect(firstSleep).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(firstRun).toBeGreaterThan(firstSleep);
|
|
||||||
// Only the named agent is restarted; the holder is untouched.
|
|
||||||
expect(events).toContain('run:mosaic-agent@coder0.service');
|
|
||||||
expect(events).not.toContain('run:mosaic-tmux-holder.service');
|
|
||||||
} finally {
|
|
||||||
await rm(home, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not let a timed-out owner drop a lock another restart broke and re-owned', async () => {
|
|
||||||
const home = await tempDir();
|
|
||||||
const runDir = join(home, 'fleet', 'run');
|
|
||||||
await mkdir(runDir, { recursive: true });
|
|
||||||
const lockPath = restartLockPath(home);
|
|
||||||
const tokenOf = async (): Promise<string> => {
|
|
||||||
const raw = await readFile(lockPath, 'utf8');
|
|
||||||
return raw.split('\n')[2]?.trim() ?? '';
|
|
||||||
};
|
|
||||||
const sleepFn = vi.fn<SleepFn>(async () => {});
|
|
||||||
|
|
||||||
// R1 acquires the lock and begins a restart that then hangs.
|
|
||||||
const r1 = await acquireRestartLock(home, sleepFn);
|
|
||||||
const tokenR1 = await tokenOf();
|
|
||||||
expect(tokenR1).not.toBe('');
|
|
||||||
|
|
||||||
// The hung R1 leaves a stale lock: rewrite its timestamp into the past while
|
|
||||||
// preserving R1's token — exactly the on-disk state a stuck owner leaves.
|
|
||||||
await writeFile(lockPath, `4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\n${tokenR1}\n`);
|
|
||||||
|
|
||||||
// R2 re-enters, sees the stale lock, and atomically takes ownership.
|
|
||||||
const r2 = await acquireRestartLock(home, sleepFn);
|
|
||||||
const tokenR2 = await tokenOf();
|
|
||||||
expect(tokenR2).not.toBe(tokenR1);
|
|
||||||
expect(sleepFn).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// R1 finally finishes and releases. It must NOT delete R2's lock — otherwise
|
|
||||||
// a third restart (R3) could acquire and interleave with R2 still running.
|
|
||||||
await r1.release();
|
|
||||||
expect(await tokenOf()).toBe(tokenR2);
|
|
||||||
|
|
||||||
// R2 releases cleanly and the lock is gone.
|
|
||||||
await r2.release();
|
|
||||||
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
||||||
|
|
||||||
await rm(home, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lets only one of several concurrent breakers proceed past a stale lock', async () => {
|
|
||||||
const home = await tempDir();
|
|
||||||
const lockPath = restartLockPath(home);
|
|
||||||
await mkdir(dirname(lockPath), { recursive: true });
|
|
||||||
|
|
||||||
// A stale lock left by a crashed owner: every concurrent re-entrant restart
|
|
||||||
// will judge it stale and try to break it at the same instant. Breaking must
|
|
||||||
// NOT grant ownership — only the atomic re-create may — so exactly one
|
|
||||||
// contender can ever hold the lock at a time. (The v2 fix wrote our own token
|
|
||||||
// during the break and read it back, so two breakers each saw their own token
|
|
||||||
// and BOTH proceeded; this guards that regression.)
|
|
||||||
await writeFile(
|
|
||||||
lockPath,
|
|
||||||
`4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\nstale-owner-token\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Yielding sleep so a waiting contender lets the current owner finish and
|
|
||||||
// release before it re-contends, instead of spinning the microtask queue.
|
|
||||||
const sleepFn: SleepFn = async () => {
|
|
||||||
await new Promise((res) => setTimeout(res, 0));
|
|
||||||
};
|
|
||||||
|
|
||||||
let active = 0;
|
|
||||||
let maxActive = 0;
|
|
||||||
const tokens: string[] = [];
|
|
||||||
const tokenOf = async (): Promise<string> => {
|
|
||||||
const raw = await readFile(lockPath, 'utf8');
|
|
||||||
return raw.split('\n')[2]?.trim() ?? '';
|
|
||||||
};
|
|
||||||
|
|
||||||
// One "restart" = acquire the lock, do work in the critical section, release.
|
|
||||||
const restartOnce = async (): Promise<void> => {
|
|
||||||
const guard = await acquireRestartLock(home, sleepFn);
|
|
||||||
active += 1;
|
|
||||||
maxActive = Math.max(maxActive, active);
|
|
||||||
// Record the token we own while we hold it, then yield to interleave with
|
|
||||||
// any other contender that might (wrongly) believe it owns the lock too.
|
|
||||||
tokens.push(await tokenOf());
|
|
||||||
await new Promise((res) => setTimeout(res, 0));
|
|
||||||
active -= 1;
|
|
||||||
await guard.release();
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Three breakers race the single stale lock simultaneously.
|
|
||||||
await Promise.all([restartOnce(), restartOnce(), restartOnce()]);
|
|
||||||
|
|
||||||
// Mutual exclusion held: never two owners at once despite concurrent breaks.
|
|
||||||
expect(maxActive).toBe(1);
|
|
||||||
// Each acquire owned with its own distinct token — no two ever shared it.
|
|
||||||
expect(new Set(tokens).size).toBe(3);
|
|
||||||
// The lock is fully released at the end.
|
|
||||||
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
||||||
} finally {
|
|
||||||
await rm(home, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lets exactly one of two breakers take over a stale lock while the other waits', async () => {
|
|
||||||
const home = await tempDir();
|
|
||||||
const lockPath = restartLockPath(home);
|
|
||||||
await mkdir(dirname(lockPath), { recursive: true });
|
|
||||||
|
|
||||||
// A single stale lock both contenders will judge stale at the same instant.
|
|
||||||
// Every transition runs under the registry mutex, so only one may take the
|
|
||||||
// lock over; the other must observe a now-fresh owner and WAIT/re-evaluate
|
|
||||||
// rather than also taking over. (A content-blind clobber let both believe
|
|
||||||
// they owned it — this asserts the mutex-gated CAS takeover instead.)
|
|
||||||
await writeFile(
|
|
||||||
lockPath,
|
|
||||||
`4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\nstale-owner-token\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Barrier the winner holds against until the loser has observed the lock
|
|
||||||
// fresh and waited at least once — forcing the exact interleaving where one
|
|
||||||
// proceeds while the other waits, deterministically rather than by timing.
|
|
||||||
let resolveLoserWaited: () => void = () => {};
|
|
||||||
const loserWaited = new Promise<void>((res) => {
|
|
||||||
resolveLoserWaited = res;
|
|
||||||
});
|
|
||||||
let sleeps = 0;
|
|
||||||
const sleepFn: SleepFn = async () => {
|
|
||||||
sleeps += 1;
|
|
||||||
resolveLoserWaited();
|
|
||||||
await new Promise((res) => setTimeout(res, 0));
|
|
||||||
};
|
|
||||||
|
|
||||||
let active = 0;
|
|
||||||
let maxActive = 0;
|
|
||||||
const tokens: string[] = [];
|
|
||||||
const tokenOf = async (): Promise<string> => {
|
|
||||||
const raw = await readFile(lockPath, 'utf8');
|
|
||||||
return raw.split('\n')[2]?.trim() ?? '';
|
|
||||||
};
|
|
||||||
|
|
||||||
let firstOwner = true;
|
|
||||||
const restartOnce = async (): Promise<void> => {
|
|
||||||
const guard = await acquireRestartLock(home, sleepFn);
|
|
||||||
active += 1;
|
|
||||||
maxActive = Math.max(maxActive, active);
|
|
||||||
tokens.push(await tokenOf());
|
|
||||||
if (firstOwner) {
|
|
||||||
// Winner: keep holding the lock until the loser has waited once, so the
|
|
||||||
// loser is guaranteed to see a FRESH owner (not the stale one) and back
|
|
||||||
// off — proving it could not also take over.
|
|
||||||
firstOwner = false;
|
|
||||||
await loserWaited;
|
|
||||||
} else {
|
|
||||||
await new Promise((res) => setTimeout(res, 0));
|
|
||||||
}
|
|
||||||
active -= 1;
|
|
||||||
await guard.release();
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Exactly two breakers race the single stale lock.
|
|
||||||
await Promise.all([restartOnce(), restartOnce()]);
|
|
||||||
|
|
||||||
// Mutual exclusion: never two owners at once (if both took over the stale
|
|
||||||
// lock, this would be 2).
|
|
||||||
expect(maxActive).toBe(1);
|
|
||||||
// Both eventually owned, each with its own distinct token.
|
|
||||||
expect(new Set(tokens).size).toBe(2);
|
|
||||||
// The loser observed the winner's fresh lock and waited — it did NOT also
|
|
||||||
// take over the stale lock.
|
|
||||||
expect(sleeps).toBeGreaterThanOrEqual(1);
|
|
||||||
// The lock is fully released at the end.
|
|
||||||
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
||||||
} finally {
|
|
||||||
await rm(home, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('attempts every agent and the holder during fleet stop even when an agent stop fails', async () => {
|
it('attempts every agent and the holder during fleet stop even when an agent stop fails', async () => {
|
||||||
const home = await tempDir();
|
const home = await tempDir();
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
import { constants } from 'node:fs';
|
import { constants } from 'node:fs';
|
||||||
import {
|
import { access, chmod, copyFile, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
||||||
access,
|
|
||||||
chmod,
|
|
||||||
copyFile,
|
|
||||||
mkdir,
|
|
||||||
open,
|
|
||||||
readFile,
|
|
||||||
stat,
|
|
||||||
unlink,
|
|
||||||
writeFile,
|
|
||||||
} from 'node:fs/promises';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { homedir, hostname, userInfo } from 'node:os';
|
import { homedir, hostname, userInfo } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -544,295 +533,6 @@ export function buildFleetServiceCommand(action: FleetServiceAction, agentName?:
|
|||||||
return ['systemctl', '--user', action, service];
|
return ['systemctl', '--user', action, service];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Poll interval (ms) while waiting for an in-flight restart's lock to clear. */
|
|
||||||
export const RESTART_LOCK_POLL_INTERVAL_MS = 250;
|
|
||||||
/**
|
|
||||||
* Maximum time (ms) a re-entrant restart waits for the in-flight restart to
|
|
||||||
* finish before it breaks the lock and proceeds anyway. A bound is required so
|
|
||||||
* a crashed holder of the lock can never deadlock the fleet permanently.
|
|
||||||
*/
|
|
||||||
export const RESTART_LOCK_MAX_WAIT_MS = 30_000;
|
|
||||||
/**
|
|
||||||
* Age (ms) past which a restart lock is treated as stale (its owner died
|
|
||||||
* without releasing it) and is broken immediately rather than waited on.
|
|
||||||
*/
|
|
||||||
export const RESTART_LOCK_STALE_MS = 60_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the path of the cross-process restart lock for a given Mosaic home.
|
|
||||||
* Kept strictly under `<mosaicHome>/fleet/run` (not the heartbeat env override)
|
|
||||||
* so the lock is scoped to the same fleet the restart acts on.
|
|
||||||
*/
|
|
||||||
export function restartLockPath(mosaicHome: string): string {
|
|
||||||
return join(mosaicHome, 'fleet', 'run', 'restart.lock');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A held restart lock; `release()` removes the lock file iff we still own it. */
|
|
||||||
interface RestartGuard {
|
|
||||||
release(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lock-file contents: pid (informational), timestamp, and a unique owner token. */
|
|
||||||
function formatRestartLockContent(token: string): string {
|
|
||||||
return `${process.pid}\n${Date.now()}\n${token}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads the owner token (line 3) from a lock file, or null if the file is
|
|
||||||
* missing/unreadable/tokenless. The token is what makes release and break
|
|
||||||
* ownership-safe: a process only ever acts on a lock whose token matches its own.
|
|
||||||
*/
|
|
||||||
async function readRestartLockToken(lockPath: string): Promise<string | null> {
|
|
||||||
let raw: string;
|
|
||||||
try {
|
|
||||||
raw = await readFile(lockPath, 'utf8');
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const token = raw.split('\n')[2]?.trim();
|
|
||||||
return token ? token : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true when a lock's contents are stale: older than RESTART_LOCK_STALE_MS,
|
|
||||||
* or unparseable (a corrupt or partially written lock left by a crashed owner).
|
|
||||||
*/
|
|
||||||
function isRestartLockContentStale(raw: string, now: number): boolean {
|
|
||||||
const stampLine = raw.split('\n')[1] ?? '';
|
|
||||||
const stamp = Number.parseInt(stampLine.trim(), 10);
|
|
||||||
if (!Number.isFinite(stamp)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return now - stamp >= RESTART_LOCK_STALE_MS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Path of the short-lived registry mutex that guards EVERY transition of the
|
|
||||||
* restart lock (acquire, release, takeover). Held only across a few filesystem
|
|
||||||
* ops — never across the restart itself — so contention clears in microseconds.
|
|
||||||
*/
|
|
||||||
function restartMutexPath(lockPath: string): string {
|
|
||||||
return `${lockPath}.mutex`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Brief back-off between registry-mutex acquisition attempts (held microseconds). */
|
|
||||||
const RESTART_MUTEX_RETRY_MS = 20;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Staleness for the internal mutex / reclaim locks, judged by the file's mtime
|
|
||||||
* rather than its CONTENT. `open(path, 'wx')` creates the inode (with a fresh
|
|
||||||
* mtime) before any token/timestamp is written into it, so a content-based check
|
|
||||||
* would momentarily see that empty file as corrupt-and-stale and could reap a
|
|
||||||
* lock another contender is still acquiring. mtime is set atomically at creation,
|
|
||||||
* so a just-created lock always reads as live; only a lock whose holder died and
|
|
||||||
* stopped touching it ages past the threshold. These locks are never held across
|
|
||||||
* the restart itself (only a couple of filesystem ops), so any mtime this old can
|
|
||||||
* belong only to a dead holder.
|
|
||||||
*/
|
|
||||||
async function isRestartLockPathStale(path: string, now: number): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const info = await stat(path);
|
|
||||||
return now - info.mtimeMs >= RESTART_LOCK_STALE_MS;
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
||||||
return false; // Gone, not stale — the caller will re-contend.
|
|
||||||
}
|
|
||||||
return false; // Can't stat — treat as live and back off rather than reap.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Path of the reclaim lock that serializes reaping of a crashed-holder mutex. */
|
|
||||||
function restartReclaimPath(mutexPath: string): string {
|
|
||||||
return `${mutexPath}.reclaim`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reap a registry mutex left behind by a process that CRASHED mid-transition —
|
|
||||||
* one whose file has aged past RESTART_LOCK_STALE_MS. Because the mutex is held
|
|
||||||
* only for a couple of filesystem ops (no sleeps, never across the restart), a
|
|
||||||
* mutex this old can only belong to a dead holder.
|
|
||||||
*
|
|
||||||
* The reap removes the dead mutex but never CREATES/holds it — acquisition stays
|
|
||||||
* the single `open('wx')` create in {@link acquireRestartMutex}, so exactly one
|
|
||||||
* contender wins ownership no matter how the reap and acquires interleave. The
|
|
||||||
* removal is made conditional by a dedicated reclaim lock: while it is held the
|
|
||||||
* dead mutex is stable (its dead holder will never touch it, and no other
|
|
||||||
* reclaimer can race), so re-reading it and removing it only if it is STILL stale
|
|
||||||
* is a true compare — a live holder's fresh mutex is never removed. This closes
|
|
||||||
* the reclaim race a content-blind rename-and-restore left open (a third
|
|
||||||
* contender slipping into the gap while a fresh mutex was moved aside).
|
|
||||||
*/
|
|
||||||
async function reclaimStaleRestartMutex(mutexPath: string): Promise<void> {
|
|
||||||
const reclaimPath = restartReclaimPath(mutexPath);
|
|
||||||
let handle: Awaited<ReturnType<typeof open>>;
|
|
||||||
try {
|
|
||||||
handle = await open(reclaimPath, 'wx');
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
// Someone is already reclaiming. If their reclaim lock is itself stale by
|
|
||||||
// mtime, its holder crashed mid-reap (the lock spans only a stat + unlink,
|
|
||||||
// microseconds) — clear it so a later pass can retry. Otherwise a live
|
|
||||||
// reclaimer has it; back off. Either way we do not reap the mutex this pass.
|
|
||||||
if (await isRestartLockPathStale(reclaimPath, Date.now())) {
|
|
||||||
await unlink(reclaimPath).catch(() => {});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// Re-check the mutex UNDER the reclaim lock and remove it only if it is STILL
|
|
||||||
// stale by mtime. A live holder's mutex is fresh and is left untouched; a dead
|
|
||||||
// holder's mutex is stable here (its holder is gone and no other reclaimer can
|
|
||||||
// race us), so this re-check is authoritative.
|
|
||||||
if (await isRestartLockPathStale(mutexPath, Date.now())) {
|
|
||||||
await unlink(mutexPath).catch(() => {});
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
await unlink(reclaimPath).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Acquire the registry mutex, BLOCKING (with brief back-offs) until held, and
|
|
||||||
* return a token-gated release. This is the single point of mutual exclusion for
|
|
||||||
* the restart lock: acquire, release, and stale/timeout takeover all run under it,
|
|
||||||
* so "read the lock, then mutate it" is atomic — no acquirer, releaser, or breaker
|
|
||||||
* can ever interleave with another. A mutex left by a crashed holder is reclaimed
|
|
||||||
* once it ages past the stale threshold.
|
|
||||||
*/
|
|
||||||
async function acquireRestartMutex(
|
|
||||||
mutexPath: string,
|
|
||||||
token: string,
|
|
||||||
): Promise<RestartGuard['release']> {
|
|
||||||
for (;;) {
|
|
||||||
let handle: Awaited<ReturnType<typeof open>>;
|
|
||||||
try {
|
|
||||||
handle = await open(mutexPath, 'wx');
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
// Staleness is judged by mtime, not content, so a mutex that exists but has
|
|
||||||
// not yet had its token written (the open-before-write window) reads as live
|
|
||||||
// and is never wrongly reaped.
|
|
||||||
if (!(await isRestartLockPathStale(mutexPath, Date.now()))) {
|
|
||||||
// A live holder has it — it will be gone in microseconds. Back off briefly.
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, RESTART_MUTEX_RETRY_MS));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await reclaimStaleRestartMutex(mutexPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// We created the mutex. Populate it with our token; if writing fails, clean up
|
|
||||||
// our own file so we never leak an empty mutex that a peer would have to reap.
|
|
||||||
try {
|
|
||||||
await handle.writeFile(formatRestartLockContent(token));
|
|
||||||
await handle.close();
|
|
||||||
} catch (err) {
|
|
||||||
await handle.close().catch(() => {});
|
|
||||||
await unlink(mutexPath).catch(() => {});
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return async (): Promise<void> => {
|
|
||||||
if ((await readRestartLockToken(mutexPath)) !== token) return;
|
|
||||||
await unlink(mutexPath).catch(() => {});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Acquire the fleet restart lock, serializing concurrent `mosaic fleet restart`
|
|
||||||
* invocations across processes. Each restart tears the tmux holder (and the
|
|
||||||
* agent sessions inside it) down and back up; without this guard a re-entrant
|
|
||||||
* restart relaunches agents against a half-torn-down holder, which fails and
|
|
||||||
* tight-loops. A re-entrant caller waits for the in-flight restart to release
|
|
||||||
* the lock (clean shutdown settled) before proceeding, breaks a stale lock left
|
|
||||||
* by a crashed owner, and after RESTART_LOCK_MAX_WAIT_MS breaks the lock to
|
|
||||||
* avoid a permanent deadlock.
|
|
||||||
*
|
|
||||||
* Correctness rests on a single invariant: EVERY transition of the lock — taking
|
|
||||||
* a free lock, taking over a stale/timed-out one, and releasing — happens under
|
|
||||||
* the registry mutex. Because the check ("is the lock free / stale / fresh?") and
|
|
||||||
* the mutation that follows it both run while the mutex is held, they are atomic:
|
|
||||||
* no other acquirer, releaser, or breaker can slip in between. That is what makes
|
|
||||||
* takeover a true compare-and-swap rather than a content-blind clobber — a normal
|
|
||||||
* `open('wx')` acquirer cannot create a fresh lock in a gap, and the original
|
|
||||||
* owner's `release()` (also mutex-gated and token-checked) cannot drop a lock a
|
|
||||||
* breaker already took over. So no interleaving lets two restarts both own the
|
|
||||||
* lock and run concurrently.
|
|
||||||
*/
|
|
||||||
export async function acquireRestartLock(
|
|
||||||
mosaicHome: string,
|
|
||||||
sleepFn: SleepFn,
|
|
||||||
): Promise<RestartGuard> {
|
|
||||||
const token = randomUUID();
|
|
||||||
const lockPath = restartLockPath(mosaicHome);
|
|
||||||
const mutexPath = restartMutexPath(lockPath);
|
|
||||||
await mkdir(dirname(lockPath), { recursive: true });
|
|
||||||
const release = async (): Promise<void> => {
|
|
||||||
// Mutex-gated and token-gated: only remove the lock if it is still ours. If
|
|
||||||
// another caller took it over (after a stale/timeout break) the token no
|
|
||||||
// longer matches and we leave their lock intact.
|
|
||||||
const releaseMutex = await acquireRestartMutex(mutexPath, token);
|
|
||||||
try {
|
|
||||||
if ((await readRestartLockToken(lockPath)) === token) {
|
|
||||||
await unlink(lockPath).catch(() => {});
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await releaseMutex();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const deadline = Date.now() + RESTART_LOCK_MAX_WAIT_MS;
|
|
||||||
for (;;) {
|
|
||||||
let owned = false;
|
|
||||||
const releaseMutex = await acquireRestartMutex(mutexPath, token);
|
|
||||||
try {
|
|
||||||
// Read and (if appropriate) mutate the lock atomically under the mutex.
|
|
||||||
let current: string | null = null;
|
|
||||||
let absent = false;
|
|
||||||
try {
|
|
||||||
current = await readFile(lockPath, 'utf8');
|
|
||||||
} catch (readErr) {
|
|
||||||
if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
||||||
absent = true;
|
|
||||||
} else {
|
|
||||||
current = null; // Unreadable/corrupt: treat as stale.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const now = Date.now();
|
|
||||||
if (absent) {
|
|
||||||
// Lock is free — take it.
|
|
||||||
await writeFile(lockPath, formatRestartLockContent(token));
|
|
||||||
owned = true;
|
|
||||||
} else {
|
|
||||||
const stale = current === null || isRestartLockContentStale(current, now);
|
|
||||||
const timedOut = now >= deadline;
|
|
||||||
if (stale || timedOut) {
|
|
||||||
process.stderr.write(
|
|
||||||
stale
|
|
||||||
? 'Breaking stale fleet restart lock.\n'
|
|
||||||
: `Timed out after ${RESTART_LOCK_MAX_WAIT_MS}ms waiting for the in-flight fleet ` +
|
|
||||||
'restart; breaking the lock.\n',
|
|
||||||
);
|
|
||||||
// Takeover is just an overwrite — safe because we hold the mutex, so no
|
|
||||||
// acquirer or releaser can touch the lock between our read and this write.
|
|
||||||
await writeFile(lockPath, formatRestartLockContent(token));
|
|
||||||
owned = true;
|
|
||||||
}
|
|
||||||
// else: a fresh restart owns it — wait below and re-evaluate.
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await releaseMutex();
|
|
||||||
}
|
|
||||||
if (owned) {
|
|
||||||
return { release };
|
|
||||||
}
|
|
||||||
await sleepFn(RESTART_LOCK_POLL_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the systemctl --user enable command for a given unit.
|
* Returns the systemctl --user enable command for a given unit.
|
||||||
* Used by the install auto-enable step to persist units across reboots.
|
* Used by the install auto-enable step to persist units across reboots.
|
||||||
@@ -1472,7 +1172,6 @@ export function isSendAccepted(capturedOutput: string): SendVerifyResult {
|
|||||||
|
|
||||||
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
|
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
|
||||||
const runner = deps.runner ?? runCommand;
|
const runner = deps.runner ?? runCommand;
|
||||||
const sleepFn = deps.sleepFn ?? defaultSleep;
|
|
||||||
const paths = resolveFleetPaths(deps.mosaicHome);
|
const paths = resolveFleetPaths(deps.mosaicHome);
|
||||||
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
|
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
|
||||||
|
|
||||||
@@ -1586,22 +1285,9 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.command(`${action} [agent]`)
|
.command(`${action} [agent]`)
|
||||||
.description(`${action} the fleet holder or one agent`)
|
.description(`${action} the fleet holder or one agent`)
|
||||||
.action(async (agent?: string) => {
|
.action(async (agent?: string) => {
|
||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
|
||||||
const roster = await loadRosterForCommand(cmd);
|
const roster = await loadRosterForCommand(cmd);
|
||||||
if (agent) {
|
if (agent) {
|
||||||
getRosterAgent(roster, agent);
|
getRosterAgent(roster, agent);
|
||||||
// Single-agent restart is guarded too: it can race a full restart that
|
|
||||||
// is tearing the shared holder down.
|
|
||||||
if (action === 'restart') {
|
|
||||||
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
|
||||||
try {
|
|
||||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
|
||||||
} finally {
|
|
||||||
await guard.release();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1612,21 +1298,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === 'restart') {
|
|
||||||
// Serialize the holder+agents teardown/relaunch behind the restart lock
|
|
||||||
// so a re-entrant restart waits for clean shutdown before relaunching,
|
|
||||||
// instead of racing a half-torn-down holder into a tight loop.
|
|
||||||
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
|
||||||
try {
|
|
||||||
await runChecked(runner, buildFleetServiceCommand(action));
|
|
||||||
for (const rosterAgent of roster.agents) {
|
|
||||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await guard.release();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await runChecked(runner, buildFleetServiceCommand(action));
|
await runChecked(runner, buildFleetServiceCommand(action));
|
||||||
for (const rosterAgent of roster.agents) {
|
for (const rosterAgent of roster.agents) {
|
||||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||||
|
|||||||
Reference in New Issue
Block a user