Add Motion: the go-to-market operating system on top of the ledger
The ledger answers which contracted capacity is sold, to whom, at what margin. It says nothing about the motion — the repeatable practice that turns a customer conversation into a scoped deployment, and turns that deployment into something the next one reuses. Motion is deliberately not a parallel entity tree. DEMAND_STAGES already is the motion, so Motion binds reusable artefacts to the stages of a demand deal that already exists: an engagement hangs off one deal, cascade deleted, one per deal by unique constraint. Nine closed kinds, each declaring which stages it serves, and a starter library of twelve templates covering all eight open stages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ export * from './ontology';
|
||||
export * from './calendar';
|
||||
export * from './learn';
|
||||
export * from './margin';
|
||||
export * from './motion';
|
||||
export * from './permissions';
|
||||
export * from './piggy-context';
|
||||
export * from './theme';
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Motion — the reusable practice that sits alongside the ledger.
|
||||
*
|
||||
* PIG already models what capacity was bought and what was sold. It does not
|
||||
* model the *motion*: the repeatable work that turns a messy customer
|
||||
* conversation into a scoped deployment, and turns that deployment into an
|
||||
* asset the next one reuses. `DEMAND_STAGES` is already exactly that sequence,
|
||||
* so Motion is deliberately NOT a parallel entity tree — every kind below
|
||||
* declares which demand stages it serves, and the artifacts hang off deals that
|
||||
* already exist.
|
||||
*
|
||||
* The loop the whole feature exists for:
|
||||
*
|
||||
* library template --instantiate--> engagement artifact --promote--> template v2
|
||||
*
|
||||
* A template is never edited in place once it has been used; promotion writes a
|
||||
* new version row pointing back at its predecessor and at the artifact that
|
||||
* proved it. That is what makes "each deployment makes the next one easier" a
|
||||
* mechanism rather than a slogan, and it is why `usageCount` and the lineage
|
||||
* columns are load-bearing rather than decoration.
|
||||
*
|
||||
* No zod in this file. `packages/core` is the ontology layer; the API route
|
||||
* modules build their schemas from these constants, so removing a value stops
|
||||
* validating rather than silently persisting.
|
||||
*/
|
||||
import { DEMAND_OPEN_STAGES, type DemandStage } from './ontology';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The nine kinds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MOTION_KINDS = [
|
||||
'discovery',
|
||||
'qualification',
|
||||
'poc',
|
||||
'proposal',
|
||||
'pricing',
|
||||
'architecture',
|
||||
'case_study',
|
||||
'narrative',
|
||||
'playbook',
|
||||
] as const;
|
||||
export type MotionKind = (typeof MOTION_KINDS)[number];
|
||||
|
||||
export const MOTION_KIND_LABELS: Record<MotionKind, string> = {
|
||||
discovery: 'Discovery',
|
||||
qualification: 'Qualification framework',
|
||||
poc: 'POC structure',
|
||||
proposal: 'Proposal blocks',
|
||||
pricing: 'Pricing and packaging',
|
||||
architecture: 'Reference architecture',
|
||||
case_study: 'Case study',
|
||||
narrative: 'Technical narrative',
|
||||
playbook: 'Deployment playbook',
|
||||
};
|
||||
|
||||
/**
|
||||
* One line each, in the register of `ontology.ts`: what the kind is *for*, not
|
||||
* what it contains. These strings are shown in the library filter and in
|
||||
* Piggy's tool description, so they are the only definition most people read.
|
||||
*/
|
||||
export const MOTION_KIND_DESCRIPTIONS: Record<MotionKind, string> = {
|
||||
discovery: 'The questions that surface what a customer is actually training, before anyone scopes it.',
|
||||
qualification: 'Weighted dimensions that turn a judgement about a deal into a score somebody can argue with.',
|
||||
poc: 'What a proof of concept must demonstrate, and what closes it — so a pilot cannot run forever.',
|
||||
proposal: 'Language blocks assembled into a proposal, so wording that survived procurement is reused.',
|
||||
pricing: 'The inputs behind a quoted price: term, commitment shape, and what the block cost.',
|
||||
architecture: 'A deployment shape that has already worked, described well enough to be copied.',
|
||||
case_study: 'A deployment written up as evidence, for the next customer who asks whether this is real.',
|
||||
narrative: 'The technical argument for why this capacity suits this workload, written once.',
|
||||
playbook: 'The end-to-end sequence for a deployment, spanning every stage rather than one.',
|
||||
};
|
||||
|
||||
/**
|
||||
* Which demand stages each kind serves.
|
||||
*
|
||||
* The closed stages are absent everywhere on purpose: a deal that is won or
|
||||
* lost has left the motion, and offering to instantiate a discovery template
|
||||
* into it would be an invitation to file work against a dead deal. `playbook`
|
||||
* spans the whole live motion, which is what distinguishes it from the eight
|
||||
* kinds that answer one stage.
|
||||
*/
|
||||
export const MOTION_KIND_STAGES: Record<MotionKind, readonly DemandStage[]> = {
|
||||
discovery: ['qualification', 'scoping'],
|
||||
qualification: ['qualification'],
|
||||
poc: ['poc'],
|
||||
proposal: ['proposal', 'procurement'],
|
||||
pricing: ['proposal', 'procurement'],
|
||||
architecture: ['scoping', 'poc', 'deployment'],
|
||||
case_study: ['qualification', 'proposal', 'expansion'],
|
||||
narrative: ['qualification', 'scoping', 'proposal'],
|
||||
playbook: DEMAND_OPEN_STAGES,
|
||||
};
|
||||
|
||||
/**
|
||||
* Private is the default, and it is a real access rule rather than a label —
|
||||
* see the header of `packages/db/src/schema/motion.ts`. A library nobody can
|
||||
* draft in privately becomes a library nobody drafts in.
|
||||
*/
|
||||
export const MOTION_VISIBILITIES = ['private', 'shared'] as const;
|
||||
export type MotionVisibility = (typeof MOTION_VISIBILITIES)[number];
|
||||
|
||||
export const MOTION_VISIBILITY_LABELS: Record<MotionVisibility, string> = {
|
||||
private: 'Private',
|
||||
shared: 'Shared',
|
||||
};
|
||||
|
||||
/** Only a `final` artifact may be promoted — see the promotion path in the API. */
|
||||
export const ARTIFACT_STATUSES = ['draft', 'review', 'final'] as const;
|
||||
export type ArtifactStatus = (typeof ARTIFACT_STATUSES)[number];
|
||||
|
||||
export const ARTIFACT_STATUS_LABELS: Record<ArtifactStatus, string> = {
|
||||
draft: 'Draft',
|
||||
review: 'In review',
|
||||
final: 'Final',
|
||||
};
|
||||
|
||||
export const ENGAGEMENT_STATUSES = ['open', 'won', 'lost', 'paused'] as const;
|
||||
export type EngagementStatus = (typeof ENGAGEMENT_STATUSES)[number];
|
||||
|
||||
export const ENGAGEMENT_STATUS_LABELS: Record<EngagementStatus, string> = {
|
||||
open: 'Open',
|
||||
won: 'Won',
|
||||
lost: 'Lost',
|
||||
paused: 'Paused',
|
||||
};
|
||||
|
||||
export function isMotionKind(value: string): value is MotionKind {
|
||||
return (MOTION_KINDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isMotionVisibility(value: string): value is MotionVisibility {
|
||||
return (MOTION_VISIBILITIES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isArtifactStatus(value: string): value is ArtifactStatus {
|
||||
return (ARTIFACT_STATUSES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isEngagementStatus(value: string): value is EngagementStatus {
|
||||
return (ENGAGEMENT_STATUSES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scoring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Each dimension is scored on this scale, inclusive. */
|
||||
export const MOTION_MIN_DIMENSION_SCORE = 0;
|
||||
export const MOTION_MAX_DIMENSION_SCORE = 4;
|
||||
|
||||
/** The full scale of a motion score. Basis points, exactly as money is cents. */
|
||||
export const MOTION_BASIS_POINTS_MAX = 10_000;
|
||||
|
||||
export interface MotionDimensionScore {
|
||||
readonly id: string;
|
||||
readonly weight: number;
|
||||
readonly score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted score in basis points of the maximum (0–10000). Weights need not
|
||||
* sum to 100.
|
||||
*
|
||||
* Basis points rather than a float for the same reason money is cents: a score
|
||||
* decides what a seller is told to do, it is persisted, and 0.55 is not 0.55 in
|
||||
* binary. The whole computation stays in integers and rounds half-up at the
|
||||
* single division, so the stored number is the number that was displayed.
|
||||
*
|
||||
* Inputs are normalised rather than rejected — a dimension carrying a
|
||||
* non-finite weight is a bug upstream, and throwing here would take a
|
||||
* dashboard down rather than under-weight one row.
|
||||
*/
|
||||
export function motionScoreBasisPoints(dimensions: readonly MotionDimensionScore[]): number {
|
||||
let weightTotal = 0;
|
||||
let weightedScoreTotal = 0;
|
||||
|
||||
for (const dimension of dimensions) {
|
||||
if (!Number.isFinite(dimension.weight) || !Number.isFinite(dimension.score)) continue;
|
||||
const weight = Math.round(dimension.weight);
|
||||
if (weight <= 0) continue;
|
||||
const score = Math.min(
|
||||
MOTION_MAX_DIMENSION_SCORE,
|
||||
Math.max(MOTION_MIN_DIMENSION_SCORE, Math.round(dimension.score)),
|
||||
);
|
||||
weightTotal += weight;
|
||||
weightedScoreTotal += weight * score;
|
||||
}
|
||||
|
||||
// No weight is not a zero score dressed up — it is "nothing was asked". Both
|
||||
// answer 0 here because a qualification with no dimensions has to render as
|
||||
// something, and NaN renders as `NaN`.
|
||||
if (weightTotal === 0) return 0;
|
||||
|
||||
const numerator = MOTION_BASIS_POINTS_MAX * weightedScoreTotal;
|
||||
const denominator = MOTION_MAX_DIMENSION_SCORE * weightTotal;
|
||||
|
||||
// Half-up, in integers. `Math.round` on the quotient would be a float
|
||||
// division first, which is exactly the step that loses the half.
|
||||
return Math.floor((2 * numerator + denominator) / (2 * denominator));
|
||||
}
|
||||
|
||||
export const MOTION_BANDS = [
|
||||
{ min: 0, max: 3499, label: 'Decline or defer', tone: 'danger' },
|
||||
{ min: 3500, max: 5499, label: 'Not yet', tone: 'warning' },
|
||||
{ min: 5500, max: 7499, label: 'Qualified', tone: 'info' },
|
||||
{ min: 7500, max: 10_000, label: 'Strategic', tone: 'positive' },
|
||||
] as const;
|
||||
export type MotionBand = (typeof MOTION_BANDS)[number];
|
||||
export type MotionBandTone = MotionBand['tone'];
|
||||
|
||||
/**
|
||||
* The band a score falls in. Boundaries are inclusive at `min`, so 3500 is
|
||||
* "Not yet" and 3499 is not — an off-by-one here changes what a seller is told
|
||||
* to do without changing any number they can see.
|
||||
*
|
||||
* Out-of-range input clamps to an end band rather than returning undefined,
|
||||
* because every caller renders this and none of them has a null branch.
|
||||
*/
|
||||
export function motionBand(basisPoints: number): MotionBand {
|
||||
const clamped = Math.min(
|
||||
MOTION_BASIS_POINTS_MAX,
|
||||
Math.max(0, Number.isFinite(basisPoints) ? Math.round(basisPoints) : 0),
|
||||
);
|
||||
return MOTION_BANDS.find((band) => clamped >= band.min && clamped <= band.max) ?? MOTION_BANDS[0];
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export const CAPABILITIES = [
|
||||
'data:import',
|
||||
'fact:review',
|
||||
'integration:connect',
|
||||
'motion:write',
|
||||
'motion:publish',
|
||||
'settings:admin',
|
||||
'book:read',
|
||||
'economics:read',
|
||||
@@ -36,6 +38,8 @@ export const TEAM_CAPABILITIES = [
|
||||
'data:import',
|
||||
'fact:review',
|
||||
'integration:connect',
|
||||
'motion:write',
|
||||
'motion:publish',
|
||||
] as const satisfies readonly Capability[];
|
||||
export type TeamCapability = (typeof TEAM_CAPABILITIES)[number];
|
||||
|
||||
@@ -93,6 +97,14 @@ export const TEAM_CAPABILITY_RULES: Readonly<Record<TeamCapability, CapabilityRu
|
||||
// research team's judgement about evidence, not a commercial authority.
|
||||
'fact:review': { teams: ['research'], minimumRole: 'admin' },
|
||||
'integration:connect': { teams: TEAMS, minimumRole: 'admin' },
|
||||
// Every team runs a motion, which is why this is not scoped to the
|
||||
// commercial pair: research authors reference architectures and technical
|
||||
// narratives as much as demand authors proposals.
|
||||
'motion:write': { teams: TEAMS, minimumRole: 'member' },
|
||||
// Publishing is what everybody else copies next quarter — a lead's
|
||||
// judgement, not a member's. Authoring privately stays open to everyone
|
||||
// precisely so this gate can be a real one.
|
||||
'motion:publish': { teams: TEAMS, minimumRole: 'lead' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,6 +52,13 @@ export const PIGGY_PAGE_ROUTES = [
|
||||
'/team',
|
||||
'/facts',
|
||||
'/learn',
|
||||
// Order is load-bearing for the first time in this list. `toPiggyPageRoute`
|
||||
// takes the FIRST match and matches on prefix, so '/motion' listed above its
|
||||
// children would swallow '/motion/library/:id' and report the whole feature
|
||||
// as one page. Most specific first.
|
||||
'/motion/library',
|
||||
'/motion/engagements',
|
||||
'/motion',
|
||||
'/settings',
|
||||
'/piggy',
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Tests for the motion scoring arithmetic.
|
||||
*
|
||||
* A qualification score is not a dashboard ornament: the band it lands in is
|
||||
* what a seller is told to do about a deal, and the row is append-only
|
||||
* evidence that the judgement was made. So the cases below pin the
|
||||
* *decisions* — the empty book, the rounding direction, and above all the band
|
||||
* boundaries, each of which a plausible-but-wrong implementation gets wrong by
|
||||
* exactly one basis point and reports no error about.
|
||||
*/
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import { DEMAND_STAGES } from '../src/ontology';
|
||||
import {
|
||||
MOTION_BANDS,
|
||||
MOTION_KINDS,
|
||||
MOTION_KIND_DESCRIPTIONS,
|
||||
MOTION_KIND_LABELS,
|
||||
MOTION_KIND_STAGES,
|
||||
isArtifactStatus,
|
||||
isMotionKind,
|
||||
isMotionVisibility,
|
||||
motionBand,
|
||||
motionScoreBasisPoints,
|
||||
} from '../src/motion';
|
||||
|
||||
describe('motionScoreBasisPoints', () => {
|
||||
it('returns 0 for an empty framework rather than NaN', () => {
|
||||
// A framework with no dimensions renders somewhere. `NaN` renders as "NaN".
|
||||
const score = motionScoreBasisPoints([]);
|
||||
assert.equal(score, 0);
|
||||
assert.ok(!Number.isNaN(score));
|
||||
});
|
||||
|
||||
it('returns 0 rather than throwing when every weight is zero', () => {
|
||||
// The naive version divides by the weight total and produces Infinity,
|
||||
// which `motionBand` would then clamp to "Strategic" — a deal nobody
|
||||
// scored recommended as the best one in the book.
|
||||
const score = motionScoreBasisPoints([
|
||||
{ id: 'fit', weight: 0, score: 4 },
|
||||
{ id: 'budget', weight: 0, score: 4 },
|
||||
]);
|
||||
assert.equal(score, 0);
|
||||
});
|
||||
|
||||
it('scores a full house at exactly the maximum', () => {
|
||||
assert.equal(
|
||||
motionScoreBasisPoints([
|
||||
{ id: 'fit', weight: 30, score: 4 },
|
||||
{ id: 'budget', weight: 70, score: 4 },
|
||||
]),
|
||||
10_000,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not require weights to sum to 100', () => {
|
||||
// Two frameworks expressing the same judgement must score the same, or the
|
||||
// number means nothing across templates.
|
||||
const outOf100 = motionScoreBasisPoints([
|
||||
{ id: 'fit', weight: 50, score: 4 },
|
||||
{ id: 'budget', weight: 50, score: 2 },
|
||||
]);
|
||||
const outOf6 = motionScoreBasisPoints([
|
||||
{ id: 'fit', weight: 3, score: 4 },
|
||||
{ id: 'budget', weight: 3, score: 2 },
|
||||
]);
|
||||
assert.equal(outOf100, 7500);
|
||||
assert.equal(outOf6, 7500);
|
||||
});
|
||||
|
||||
it('rounds half-up rather than truncating', () => {
|
||||
// 10000 × 5 / (4 × 8) = 1562.5 exactly. Truncation gives 1562, which is
|
||||
// the whole difference between this and a naive `Math.floor` — small, and
|
||||
// it moves a score across a band boundary once every few hundred deals.
|
||||
const score = motionScoreBasisPoints([
|
||||
{ id: 'fit', weight: 5, score: 1 },
|
||||
{ id: 'budget', weight: 3, score: 0 },
|
||||
]);
|
||||
assert.equal(score, 1563);
|
||||
});
|
||||
|
||||
it('does not let a zero-weight dimension move the total', () => {
|
||||
// The case that flatters: a dimension somebody scored 4 and weighted out
|
||||
// of the framework must not drag the score up on its way past.
|
||||
const withoutIt = motionScoreBasisPoints([{ id: 'fit', weight: 10, score: 2 }]);
|
||||
const withIt = motionScoreBasisPoints([
|
||||
{ id: 'fit', weight: 10, score: 2 },
|
||||
{ id: 'vanity', weight: 0, score: 4 },
|
||||
]);
|
||||
assert.equal(withoutIt, 5000);
|
||||
assert.equal(withIt, withoutIt);
|
||||
});
|
||||
|
||||
it('always returns an integer inside 0–10000', () => {
|
||||
for (const weight of [1, 3, 7, 17, 100]) {
|
||||
for (const score of [0, 1, 2, 3, 4]) {
|
||||
const basisPoints = motionScoreBasisPoints([
|
||||
{ id: 'a', weight, score },
|
||||
{ id: 'b', weight: weight + 1, score: 4 - score },
|
||||
]);
|
||||
assert.ok(Number.isInteger(basisPoints), `${weight}/${score} produced a fraction`);
|
||||
assert.ok(basisPoints >= 0 && basisPoints <= 10_000, `${basisPoints} is out of range`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('motionBand', () => {
|
||||
/**
|
||||
* The boundaries, one assertion per edge. Inclusive at `min`: an
|
||||
* implementation using `>` instead of `>=` passes every other test in this
|
||||
* file and tells a seller to defer a deal that qualified.
|
||||
*/
|
||||
it('is inclusive at the lower edge of every band', () => {
|
||||
assert.equal(motionBand(3500).label, 'Not yet');
|
||||
assert.equal(motionBand(5500).label, 'Qualified');
|
||||
assert.equal(motionBand(7500).label, 'Strategic');
|
||||
});
|
||||
|
||||
it('keeps the basis point below each edge in the band underneath', () => {
|
||||
assert.equal(motionBand(3499).label, 'Decline or defer');
|
||||
assert.equal(motionBand(5499).label, 'Not yet');
|
||||
assert.equal(motionBand(7499).label, 'Qualified');
|
||||
});
|
||||
|
||||
it('covers both ends of the scale', () => {
|
||||
assert.equal(motionBand(0).label, 'Decline or defer');
|
||||
assert.equal(motionBand(10_000).label, 'Strategic');
|
||||
});
|
||||
|
||||
it('leaves no gap and no overlap across the whole range', () => {
|
||||
// Written as a sweep because a hand-edited band table is exactly the kind
|
||||
// of data where a typo produces a score that belongs to two bands, or none.
|
||||
for (let basisPoints = 0; basisPoints <= 10_000; basisPoints += 1) {
|
||||
const matches = MOTION_BANDS.filter(
|
||||
(band) => basisPoints >= band.min && basisPoints <= band.max,
|
||||
);
|
||||
assert.equal(matches.length, 1, `${basisPoints} matched ${matches.length} bands`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the kind register', () => {
|
||||
it('labels and describes every kind', () => {
|
||||
for (const kind of MOTION_KINDS) {
|
||||
assert.ok(MOTION_KIND_LABELS[kind], `${kind} has no label`);
|
||||
assert.ok(MOTION_KIND_DESCRIPTIONS[kind], `${kind} has no description`);
|
||||
}
|
||||
});
|
||||
|
||||
it('maps every kind onto stages that actually exist', () => {
|
||||
// The stage list is the demand pipeline, not a second vocabulary. A typo
|
||||
// here would produce a template nothing can ever be filed against.
|
||||
for (const kind of MOTION_KINDS) {
|
||||
const stages = MOTION_KIND_STAGES[kind];
|
||||
assert.ok(stages.length > 0, `${kind} serves no stage`);
|
||||
for (const stage of stages) {
|
||||
assert.ok(DEMAND_STAGES.includes(stage), `${kind} claims unknown stage ${stage}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('never files a kind against a closed stage', () => {
|
||||
// A won or lost deal has left the motion; offering to instantiate into it
|
||||
// is an invitation to do work against a dead deal.
|
||||
for (const kind of MOTION_KINDS) {
|
||||
for (const stage of MOTION_KIND_STAGES[kind]) {
|
||||
assert.ok(stage !== 'closed_won' && stage !== 'closed_lost', `${kind} serves ${stage}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('gives the playbook the whole live motion', () => {
|
||||
assert.equal(MOTION_KIND_STAGES.playbook.length, 8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('type guards', () => {
|
||||
it('accepts members and rejects near misses', () => {
|
||||
assert.equal(isMotionKind('playbook'), true);
|
||||
assert.equal(isMotionKind('Playbook'), false);
|
||||
assert.equal(isMotionVisibility('shared'), true);
|
||||
assert.equal(isMotionVisibility('public'), false, 'shared is book-wide, not public');
|
||||
assert.equal(isArtifactStatus('final'), true);
|
||||
assert.equal(isArtifactStatus('published'), false);
|
||||
});
|
||||
});
|
||||
@@ -134,7 +134,14 @@ describe('reads', () => {
|
||||
describe('the whole role × capability matrix', () => {
|
||||
const EXPECTED: Readonly<Record<TeamRole, readonly Capability[]>> = {
|
||||
viewer: ['book:read', 'team:read'],
|
||||
member: ['book:read', 'economics:read', 'team:read', 'deal:write', 'activity:write'],
|
||||
member: [
|
||||
'book:read',
|
||||
'economics:read',
|
||||
'team:read',
|
||||
'deal:write',
|
||||
'activity:write',
|
||||
'motion:write',
|
||||
],
|
||||
lead: [
|
||||
'book:read',
|
||||
'economics:read',
|
||||
@@ -142,6 +149,8 @@ describe('the whole role × capability matrix', () => {
|
||||
'deal:write',
|
||||
'activity:write',
|
||||
'commitment:write',
|
||||
'motion:write',
|
||||
'motion:publish',
|
||||
],
|
||||
admin: [
|
||||
'book:read',
|
||||
@@ -153,6 +162,8 @@ describe('the whole role × capability matrix', () => {
|
||||
'contract:sign',
|
||||
'data:import',
|
||||
'integration:connect',
|
||||
'motion:write',
|
||||
'motion:publish',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -188,6 +199,8 @@ describe('the whole role × capability matrix', () => {
|
||||
'data:import',
|
||||
'fact:review',
|
||||
'integration:connect',
|
||||
'motion:write',
|
||||
'motion:publish',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user